From 0cfcfaf8994528814bfb791c2724548479ea51c2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 1 Jul 2025 20:38:18 -0400 Subject: [PATCH] 3rd large migration to outbox. --- .idea/deviceManager.xml | 13 + .idea/kotlinNotebook.xml | 6 + .idea/kotlinc.xml | 6 + README.md | 2 +- .../com/vitorpamplona/amethyst/Amethyst.kt | 32 +- .../com/vitorpamplona/amethyst/DebugUtils.kt | 6 +- .../amethyst/LocalPreferences.kt | 68 +- .../vitorpamplona/amethyst/ServiceManager.kt | 142 - .../vitorpamplona/amethyst/model/Account.kt | 3077 ++++------------- .../amethyst/model/AccountSettings.kt | 100 +- .../amethyst/model/AntiSpamFilter.kt | 10 +- .../vitorpamplona/amethyst/model/Channel.kt | 48 +- .../vitorpamplona/amethyst/model/Constants.kt | 49 + .../amethyst/model/HashtagIcon.kt | 4 +- .../amethyst/model/LocalCache.kt | 554 +-- .../com/vitorpamplona/amethyst/model/Note.kt | 59 +- .../com/vitorpamplona/amethyst/model/User.kt | 30 +- .../edits/PrivateStorageRelayListState.kt | 117 + .../model/emphChat/EphemeralChatListState.kt | 69 +- .../model/localRelays/LocalRelayListState.kt | 62 + .../AccountOutboxRelayState.kt | 55 + .../NotificationInboxRelayState.kt} | 45 +- .../nip01UserMetadata/UserMetadataState.kt | 124 + .../FollowListOutboxRelays.kt | 100 + .../model/nip02FollowLists/FollowListState.kt | 171 + .../nip02FollowLists/FollowsPerOutboxRelay.kt | 94 + .../model/nip17Dms/DmInboxRelayState.kt | 59 + .../model/nip17Dms/DmRelayListState.kt | 114 + .../model/nip18Reposts/RepostAction.kt | 57 + .../model/nip25Reactions/ReactionAction.kt | 109 + .../nip28PublicChats/PublicChatListState.kt | 76 +- .../model/nip30CustomEmojis/EmojiPackState.kt | 41 +- .../nip38UserStatuses/UserStatusAction.kt | 71 + .../model/nip50Search/SearchRelayListState.kt | 114 + .../model/nip51Lists/BlockPeopleListState.kt | 158 + .../model/nip51Lists/GeohashListState.kt | 150 + .../model/nip51Lists/HashtagListState.kt | 150 + .../model/nip51Lists/HiddenUsersState.kt | 116 + .../model/nip51Lists/MuteListState.kt | 185 + .../model/nip56Reports/ReportAction.kt | 71 + .../nip65RelayList/Nip65RelayListState.kt | 149 + .../nip65RelayList/OutboxRelaySetState.kt | 103 + .../nip72Communities/CommunityListState.kt | 177 + .../nip78AppSpecific/AppSpecificState.kt | 111 + .../FileStorageServerListState.kt | 84 + .../nipB7Blossom/BlossomServerListState.kt | 91 + .../observables/LatestByKindAndAuthor.kt | 7 +- .../serverList/MergedFollowListsState.kt | 84 + .../MergedFollowPlusMineRelayListsState.kt | 86 + .../model/serverList/MergedServerListState.kt | 74 + .../serverList/TrustedRelayListsState.kt | 88 + .../model/topNavFeeds/CommunityRelayLoader.kt | 81 + .../topNavFeeds/FeedTopNavFilterState.kt | 86 + .../topNavFeeds/IFeedFlowsType.kt} | 15 +- .../model/topNavFeeds/IFeedTopNavFilter.kt | 22 +- .../topNavFeeds/IFeedTopNavPerRelayFilter.kt | 23 + .../IFeedTopNavPerRelayFilterSet.kt | 23 + .../MergedTopFeedAuthorListsState.kt | 112 + .../model/topNavFeeds/OutboxLoaderState.kt | 53 + .../model/topNavFeeds/OutboxRelayLoader.kt | 80 + .../AllFollowsByOutboxTopNavFilter.kt | 140 + ...AllFollowsByOutboxTopNavPerRelayFilter.kt} | 28 +- ...lFollowsByOutboxTopNavPerRelayFilterSet.kt | 28 + .../allFollows/AllFollowsFeedFlow.kt | 57 + .../topNavFeeds/aroundMe/AroundMeExpander.kt | 63 + .../topNavFeeds/aroundMe/AroundMeFeedFlow.kt | 56 + .../aroundMe/LocationTopNavFilter.kt | 65 + .../aroundMe/LocationTopNavPerRelayFilter.kt | 32 + .../LocationTopNavPerRelayFilterSet.kt | 28 + .../topNavFeeds/global/GlobalFeedFlow.kt | 34 +- .../topNavFeeds/global/GlobalTopNavFilter.kt | 52 + .../global/GlobalTopNavPerRelayFilter.kt | 14 +- .../global/GlobalTopNavPerRelayFilterSet.kt | 28 + .../hashtag/HashtagTopNavFilter.kt | 68 + .../hashtag/HashtagTopNavPerRelayFilter.kt | 24 +- .../hashtag/HashtagTopNavPerRelayFilterSet.kt | 28 + .../topNavFeeds/noteBased/NoteFeedFlow.kt | 131 + .../AllCommunitiesTopNavFilter.kt | 59 + .../AllCommunitiesTopNavPerRelayFilter.kt | 27 + .../AllCommunitiesTopNavPerRelayFilterSet.kt | 28 + .../author/AuthorsByOutboxTopNavFilter.kt | 59 + .../AuthorsByOutboxTopNavPerRelayFilter.kt | 29 + .../AuthorsByOutboxTopNavPerRelayFilterSet.kt | 28 + .../community/SingleCommunityTopNavFilter.kt | 115 + .../SingleCommunityTopNavPerRelayFilter.kt | 30 + .../SingleCommunityTopNavPerRelayFilterSet.kt | 28 + .../muted/MutedAuthorsByOutboxTopNavFilter.kt | 59 + ...utedAuthorsByOutboxTopNavPerRelayFilter.kt | 29 + ...dAuthorsByOutboxTopNavPerRelayFilterSet.kt | 28 + .../topNavFeeds/unknown/UnknownFeedFlow.kt | 37 + .../unknown/UnknownTopNavFilter.kt} | 32 +- .../unknown/UnknownTopNavPerRelayFilterSet.kt | 13 +- .../model/torState/TorRelayEvaluation.kt | 50 + .../model/torState/TorRelaySettings.kt | 31 + .../amethyst/model/torState/TorRelayState.kt | 107 + .../service/Nip11RelayInfoRetriever.kt | 65 +- .../amethyst/service/ZapPaymentHandler.kt | 44 +- .../service/notifications/RegisterAccounts.kt | 13 +- .../service/okhttp/DualHttpClientManager.kt | 4 +- .../service/okhttp/OkHttpWebSocket.kt | 59 +- .../ProxySettingsAnchor.kt} | 42 +- .../relayClient/CacheClientConnector.kt | 16 +- .../service/relayClient/RelayLogger.kt | 15 +- .../relayClient/RelayProxyClientConnector.kt | 71 + .../service/relayClient/RelaySpeedLogger.kt | 23 +- .../authCommand/model/AuthCoordinator.kt | 4 +- .../ComposeSubscriptionManager.kt | 4 - .../eoseManagers/BaseEoseManager.kt | 2 +- .../eoseManagers/PerUniqueIdEoseManager.kt | 17 +- .../PerUserAndFollowListEoseManager.kt | 19 +- .../eoseManagers/PerUserEoseManager.kt | 19 +- .../eoseManagers/SingleSubEoseManager.kt | 19 +- .../SingleSubNoEoseCacheEoseManager.kt | 8 +- .../compose/DisplayNotifyMessages.kt | 15 +- .../notifyCommand/model/NotifyCoordinator.kt | 4 +- .../notifyCommand/model/NotifyRequest.kt | 4 +- .../model/NotifyRequestsCache.kt | 5 +- .../RelaySubscriptionsCoordinator.kt | 4 +- .../account/AccountFilterAssembler.kt | 2 +- .../AccountFilterAssemblerSubscription.kt | 2 +- .../reqCommand/account/AccountObservers.kt | 4 +- .../metadata/AccountMetadataEoseManager.kt | 63 +- .../FilterAccountInfoAndListsFromKey.kt | 65 +- .../FilterBasicAccountInfoFromKeys.kt | 44 +- .../metadata/FilterDraftsAndReportsFromKey.kt | 34 +- .../metadata/FilterFollowsAndMutesFromKey.kt | 44 +- .../metadata/FilterLastPostsFromKey.kt | 22 +- .../AccountNotificationsEoseManager.kt | 47 +- .../FilterNotificationsToPubkey.kt | 92 +- .../AccountGiftWrapsEoseManager.kt | 52 +- .../nip59GiftWraps/FilterGiftWrapsToPubkey.kt | 25 +- .../ChannelFinderFilterAssemblyGroup.kt | 2 +- .../reqCommand/channel/ChannelObservers.kt | 10 +- ...adataAndLiveActivityWatcherSubAssembler.kt | 48 +- .../ChannelLoaderSubAssembler.kt | 6 +- .../ChannelMetadataWatcherSubAssembler.kt | 16 +- .../FilterChannelMetadataCreationById.kt | 52 +- .../FilterChannelMetadataUpdatesById.kt | 26 +- .../FilterLiveStreamUpdatesByAddress.kt | 29 +- .../LiveActivityWatcherSubAssembly.kt | 19 +- .../event/EventFinderFilterAssembler.kt | 2 +- .../loaders/FilterMissingAddressables.kt | 69 +- .../event/loaders/FilterMissingEvents.kt | 34 +- .../loaders/NoteEventLoaderSubAssembler.kt | 2 +- .../watchers/EventWatcherSubAssembler.kt | 35 +- .../FilterRepliesAndReactionsToAddresses.kt | 118 +- .../FilterRepliesAndReactionsToNotes.kt | 122 +- .../nwc/FilterNWCPaymentsFromRequests.kt | 32 +- .../NWCFinderFilterAssemblerSubscription.kt | 68 + .../nwc/NWCPaymentFilterAssembler.kt | 11 +- .../nwc/NWCPaymentWatcherSubAssembler.kt | 31 +- .../user/UserFinderFilterAssembler.kt | 4 +- .../UserFinderFilterAssemblerSubscription.kt | 12 +- .../reqCommand/user/UserObservers.kt | 14 +- .../user/loaders/FilterUserMetadataForKey.kt | 59 +- .../user/loaders/UserLoaderSubAssembler.kt | 31 +- .../user/watchers/FilterReportsToKey.kt | 41 +- .../user/watchers/FilterUserMetadataForKey.kt | 55 +- .../user/watchers/UserWatcherSubAssembler.kt | 50 +- .../searchCommand/SearchFilterAssembler.kt | 6 +- .../subassemblies/FilterByAddress.kt | 44 +- .../subassemblies/FilterByAuthor.kt | 18 +- .../subassemblies/FilterByEvent.kt | 15 +- .../subassemblies/SearchPeopleByName.kt | 32 +- .../subassemblies/SearchPostsByText.kt | 128 +- .../SearchWatcherSubAssembler.kt | 49 +- .../amethyst/service/relays/EOSE.kt | 32 +- .../uploads/blossom/BlossomUploader.kt | 6 +- .../service/uploads/nip96/Nip96Uploader.kt | 7 +- .../vitorpamplona/amethyst/ui/MainActivity.kt | 16 +- .../amethyst/ui/actions/EditPostView.kt | 4 +- .../amethyst/ui/actions/EditPostViewModel.kt | 8 +- .../amethyst/ui/actions/NewMediaModel.kt | 8 +- .../amethyst/ui/actions/NewMediaView.kt | 4 +- .../ui/actions/RelaySelectionDialog.kt | 62 +- .../mediaServers/BlossomServersViewModel.kt | 2 +- .../mediaServers/NIP96ServersViewModel.kt | 2 +- .../amethyst/ui/components/RichTextViewer.kt | 13 +- .../markdown/RenderContentAsMarkdown.kt | 2 +- .../amethyst/ui/dal/FilterByListParams.kt | 76 +- .../amethyst/ui/navigation/AppNavigation.kt | 15 +- .../amethyst/ui/navigation/BottomBarRoute.kt | 34 + .../amethyst/ui/navigation/DrawerContent.kt | 10 +- .../NormalizedRelayUrlSerializer.kt | 37 +- .../amethyst/ui/navigation/RouteMaker.kt | 4 +- .../amethyst/ui/navigation/Routes.kt | 17 +- .../vitorpamplona/amethyst/ui/note/Loaders.kt | 2 +- .../amethyst/ui/note/NoteCompose.kt | 2 +- .../amethyst/ui/note/ReactionsRow.kt | 13 + .../amethyst/ui/note/RelayCompose.kt | 3 +- .../amethyst/ui/note/RelayListRow.kt | 17 +- .../amethyst/ui/note/UpdateZapAmountDialog.kt | 13 +- .../amethyst/ui/note/UserProfilePicture.kt | 2 +- .../creators/uploads/ImageVideoDescription.kt | 2 +- .../userSuggestions/UserSuggestionState.kt | 2 +- .../ui/note/elements/DisplayHashtags.kt | 5 +- .../ui/note/elements/DisplayLocation.kt | 2 + .../ui/note/elements/DisplayReward.kt | 2 - .../note/elements/DisplayUncitedHashtags.kt | 5 +- .../nip22Comments/CommentPostViewModel.kt | 21 +- .../note/nip22Comments/DisplayExternalId.kt | 6 +- .../nip22Comments/DisplayGeoHashExternalId.kt | 17 +- .../nip22Comments/DisplayHashtagExternalId.kt | 17 +- .../nip22Comments/GenericCommentPostScreen.kt | 8 +- .../amethyst/ui/note/types/AudioTrack.kt | 2 +- .../amethyst/ui/note/types/CommunityHeader.kt | 5 +- .../amethyst/ui/note/types/Emoji.kt | 4 +- .../amethyst/ui/note/types/Git.kt | 3 +- .../types/NIP90ContentDiscoveryResponse.kt | 2 +- .../amethyst/ui/note/types/Poll.kt | 2 +- .../amethyst/ui/note/types/PrivateMessage.kt | 2 +- .../amethyst/ui/note/types/RelayList.kt | 25 +- .../amethyst/ui/note/types/Text.kt | 2 +- .../amethyst/ui/note/types/Video.kt | 2 +- .../ui/screen/AccountStateViewModel.kt | 25 +- .../amethyst/ui/screen/FollowListState.kt | 14 +- .../ui/screen/loggedIn/AccountViewModel.kt | 142 +- .../ui/screen/loggedIn/LoggedInPage.kt | 98 +- .../loggedIn/chats/feed/ChatMessageCompose.kt | 2 +- .../feed/types/RenderCreateChannelNote.kt | 38 +- .../datasource/ChatroomFilterAssembler.kt | 2 +- .../datasource/ChatroomFilterSubAssembler.kt | 14 +- .../privateDM/datasource/FilterNip04DMs.kt | 86 +- .../privateDM/send/ChatNewMessageViewModel.kt | 2 +- .../datasource/ChannelFilterAssembler.kt | 2 +- .../ChannelFromUserFilterSubAssembler.kt | 10 +- .../ChannelPublicFilterSubAssembler.kt | 10 +- .../FilterMessagesToEphemeralChat.kt | 26 +- .../FilterMessagesToLiveStream.kt | 25 +- .../FilterMessagesToPublicChat.kt | 25 +- .../FilterMyMessagesToEphemeralChat.kt | 26 +- .../FilterMyMessagesToLiveActivities.kt | 25 +- .../FilterMyMessagesToPublicChat.kt | 25 +- .../header/ShortEphemeralChatChannelHeader.kt | 24 +- .../metadata/NewEphemeralChatMetaViewModel.kt | 5 +- .../metadata/NewEphemeralChatScreen.kt | 6 +- .../metadata/ChannelMetadataScreen.kt | 2 +- .../metadata/ChannelMetadataViewModel.kt | 26 +- .../LongLiveActivityChannelHeader.kt | 2 +- .../send/ChannelNewMessageViewModel.kt | 2 +- .../chats/rooms/ChatroomHeaderCompose.kt | 8 +- .../rooms/dal/ChatroomListKnownFeedFilter.kt | 6 +- .../datasource/ChatroomListFilterAssembler.kt | 2 +- .../DMsFromUserFilterSubAssembler.kt | 58 +- .../FilterFollowingEphemeralChats.kt | 49 +- .../datasource/FilterFollowingPublicChats.kt | 49 +- .../FilterLastMessageFollowingPublicChats.kt | 84 +- .../rooms/datasource/FilterNip04DMsFromMe.kt | 43 + .../datasource/FilterNip04DMsToAndFromMe.kt | 56 - .../rooms/datasource/FilterNip04DMsToMe.kt | 43 + .../FollowingEphemeralChatSubAssembler.kt | 12 +- .../FollowingPublicChatSubAssembler.kt | 18 +- .../chats/utils/ChatFileUploadDialog.kt | 2 +- .../CommunityFeedFilterSubAssembler.kt | 25 +- .../datasource/CommunityFilterAssembler.kt | 2 +- .../datasource/FilterCommunityPosts.kt | 21 +- .../datasource/DiscoveryFilterAssembler.kt | 7 +- ...DiscoveryFollowsDiscoverySubAssembler1.kt} | 57 +- .../DiscoveryFollowsDiscoverySubAssembler2.kt | 94 + ...DiscoveryFollowsDiscoverySubAssembler3.kt} | 46 +- ...MixGeohashHashtagsDiscoverySubAssembler.kt | 123 - .../DiscoverLongFormFeedFilter.kt | 6 +- .../nip23LongForm/SubAssemblyHelper.kt | 57 + .../FilterLongFormByAllCommunities.kt | 85 + .../subassemblies/FilterLongFormByAuthors.kt | 95 + .../FilterLongFormByCommunity.kt | 88 + .../subassemblies/FilterLongFormByFollows.kt | 52 +- .../subassemblies/FilterLongFormByGeohash.kt | 50 +- .../subassemblies/FilterLongFormByHashtag.kt | 54 +- .../subassemblies/FilterLongFormGlobal.kt | 50 + .../nip28Chats/DiscoverChatFeedFilter.kt | 21 +- .../discover/nip28Chats/RenderChannelThumb.kt | 20 +- .../discover/nip28Chats/SubAssemblyHelper.kt | 57 + .../FilterPublicChatsByAllCommunities.kt | 92 + .../FilterPublicChatsByAuthors.kt | 102 + .../FilterPublicChatsByCommunity.kt | 95 + .../FilterPublicChatsByFollows.kt | 52 +- .../FilterPublicChatsByGeohash.kt | 49 +- .../FilterPublicChatsByHashtag.kt | 49 +- .../subassemblies/FilterPublicChatsGlobal.kt} | 48 +- .../DiscoverFollowSetsFeedFilter.kt | 6 +- .../nip51FollowSets/SubAssemblyHelper.kt | 57 + .../FilterFollowSetsByAllCommunities.kt | 86 + .../FilterFollowSetsByAuthors.kt | 95 + .../FilterFollowSetsByCommunity.kt | 88 + .../FilterFollowSetsByFollows.kt | 52 +- .../FilterFollowSetsByGeohash.kt | 53 +- .../FilterFollowSetsByHashtag.kt | 56 +- .../subassemblies/FilterFollowSetsGlobal.kt} | 45 +- .../DiscoverLiveFeedFilter.kt | 26 +- .../nip53LiveActivities/LiveActivityCard.kt | 17 +- .../nip53LiveActivities/SubAssemblyHelper.kt | 57 + .../FilterLiveActivitiesByAllCommunities.kt | 87 + .../FilterLiveActivitiesByAuthors.kt | 107 + .../FilterLiveActivitiesByCommunity.kt | 89 + .../FilterLiveActivitiesByFollows.kt | 71 +- .../FilterLiveActivitiesByGeohash.kt | 55 +- .../FilterLiveActivitiesByHashtag.kt | 55 +- .../FilterLiveActivitiesGlobal.kt} | 49 +- .../nip72Communities/CommunityCard.kt | 20 +- .../DiscoverCommunityFeedFilter.kt | 20 +- .../nip72Communities/SubAssemblyHelper.kt | 57 + .../FilterCommunitiesByAllCommunities.kt | 81 + .../FilterCommunitiesByAuthors.kt | 96 + .../FilterCommunitiesByCommunity.kt | 71 + .../FilterCommunitiesByFollows.kt | 57 +- .../FilterCommunitiesByGeohash.kt | 75 + .../FilterCommunitiesByHashtag.kt | 76 + ...yGeohash.kt => FilterCommunitiesGlobal.kt} | 51 +- .../nip90DVMs/DiscoverNIP89FeedFilter.kt | 6 +- .../discover/nip90DVMs/SubAssemblyHelper.kt | 57 + .../FilterContentDVMsByAllCommunities.kt | 85 + .../FilterContentDVMsByAuthors.kt | 96 + .../FilterContentDVMsByCommunity.kt | 88 + .../FilterContentDVMsByFollows.kt | 52 +- .../FilterContentDVMsByGeohash.kt | 49 +- .../FilterContentDVMsByHashtag.kt | 49 +- .../subassemblies/FilterContentDVMsGlobal.kt | 53 + .../DiscoverMarketplaceFeedFilter.kt | 6 +- .../nip99Classifieds/NewProductScreen.kt | 7 +- .../nip99Classifieds/NewProductViewModel.kt | 15 +- .../nip99Classifieds/SubAssemblyHelper.kt | 57 + .../FilterClassifiedsByAllCommunities.kt | 85 + .../FilterClassifiedsByAuthors.kt | 95 + .../FilterClassifiedsByCommunity.kt | 88 + .../FilterClassifiedsByFollows.kt | 52 +- .../FilterClassifiedsByGeohash.kt | 49 +- .../FilterClassifiedsByHashtag.kt | 49 +- .../subassemblies/FilterClassifiedsGlobal.kt | 52 + .../dvms/DvmContentDiscoveryScreen.kt | 2 +- .../NIP90ContentDiscoveryResponseFilter.kt | 4 +- .../screen/loggedIn/geohash/GeoHashScreen.kt | 28 +- .../loggedIn/geohash/dal/GeoHashFeedFilter.kt | 7 +- .../geohash/dal/GeoHashFeedViewModel.kt | 7 +- .../datasource/FilterPostsByGeohash.kt | 94 +- .../GeoHashFeedFilterSubAssembler.kt | 10 +- .../datasource/GeoHashFilterAssembler.kt | 4 +- .../GeoHashFilterAssemblerSubscription.kt | 12 +- .../screen/loggedIn/hashtag/HashtagScreen.kt | 24 +- .../loggedIn/hashtag/dal/HashtagFeedFilter.kt | 4 +- .../hashtag/dal/HashtagFeedViewModel.kt | 7 +- .../datasource/FilterPostsByHashtags.kt | 120 +- .../HashtagFeedFilterSubAssembler.kt | 10 +- .../datasource/HashtagFilterAssembler.kt | 4 +- .../HashtagFilterAssemblerSubscription.kt | 11 +- .../loggedIn/home/ShortNotePostScreen.kt | 7 +- .../loggedIn/home/ShortNotePostViewModel.kt | 21 +- .../home/dal/HomeConversationsFeedFilter.kt | 11 +- .../loggedIn/home/dal/HomeLiveFilter.kt | 54 +- .../home/dal/HomeNewThreadFeedFilter.kt | 24 +- .../home/datasource/HomeFilterAssembler.kt | 8 +- .../MixGeohashHashtagsCommunityEoseManager.kt | 83 - .../nip01Core/FilterHomePostsByGeohashes.kt | 101 + .../FilterHomePostsByGlobal.kt} | 112 +- .../nip01Core/FilterHomePostsByHashtags.kt | 107 + .../nip01Geohash/FilterPostsByGeohashes.kt | 66 - .../nip01Geohash/GeohashEventsEoseManager.kt | 78 - .../FilterHomePostsByHashtags.kt | 71 - .../HashtagEventsFilterSubAssembler.kt | 80 - ...eohashScopes.kt => FilterPostsByScopes.kt} | 26 +- .../FilterHomePostsByAllFollows.kt | 66 + .../nip65Follows/FilterHomePostsByAuthors.kt | 142 + .../HomeOutboxEventsEoseManager.kt | 44 +- .../CommunityEventsFilterSubAssembler.kt | 76 - .../FilterHomePostsByAllCommunities.kt | 84 + .../FilterHomePostsFromCommunities.kt | 102 +- .../dal/NotificationFeedFilter.kt | 13 +- .../datasource/FilterUserProfileFollowers.kt | 37 +- .../datasource/FilterUserProfileLists.kt | 55 +- .../datasource/FilterUserProfileMedia.kt | 55 +- .../datasource/FilterUserProfileMetadata.kt | 55 +- .../datasource/FilterUserProfilePosts.kt | 99 +- .../FilterUserProfileZapReceived.kt | 37 +- .../datasource/UserProfileFilterAssembler.kt | 2 +- .../UserProfileFollowersFilterSubAssembler.kt | 13 +- .../UserProfileMediaFilterSubAssembler.kt | 14 +- .../UserProfileMetadataFilterSubAssembler.kt | 26 +- .../UserProfilePostsFilterSubAssembler.kt | 14 +- .../UserProfileZapsFilterSubAssembler.kt | 12 +- .../dal/UserProfileGalleryFeedFilter.kt | 4 +- .../profile/hashtags/TabFollowedTags.kt | 6 +- .../loggedIn/profile/relays/RelayFeedView.kt | 4 +- .../profile/relays/RelayFeedViewModel.kt | 10 +- .../screen/loggedIn/qrcode/QrCodeScanner.kt | 8 +- .../ui/screen/loggedIn/qrcode/ShowQRDialog.kt | 2 +- .../loggedIn/relays/AllRelayListScreen.kt | 46 - .../loggedIn/relays/RelayInformationDialog.kt | 23 +- .../relays/common/BasicRelaySetupInfo.kt | 16 +- .../common/BasicRelaySetupInfoClickableRow.kt | 9 +- .../common/BasicRelaySetupInfoDialog.kt | 17 +- .../relays/common/BasicRelaySetupInfoModel.kt | 17 +- .../relays/common/RelayNameAndRemoveButton.kt | 5 +- .../relays/common/RelayUrlEditField.kt | 18 +- .../loggedIn/relays/dm/DMRelayListView.kt | 2 +- .../relays/dm/DMRelayListViewModel.kt | 5 +- .../relays/kind3/Kind3RelayListView.kt | 824 ----- .../relays/kind3/Kind3RelayListViewModel.kt | 293 -- .../relays/local/LocalRelayListView.kt | 2 +- .../relays/local/LocalRelayListViewModel.kt | 7 +- .../nip37/PrivateOutboxRelayListView.kt | 2 +- .../nip37/PrivateOutboxRelayListViewModel.kt | 5 +- .../relays/nip65/Nip65RelayListView.kt | 4 +- .../relays/nip65/Nip65RelayListViewModel.kt | 36 +- .../Kind3RelaySetupInfoProposalDialog.kt | 118 - .../Kind3RelaySetupInfoProposalRow.kt | 199 -- .../relays/search/SearchRelayListView.kt | 2 +- .../relays/search/SearchRelayListViewModel.kt | 5 +- .../loggedIn/search/SearchBarViewModel.kt | 4 +- .../settings/SecurityFiltersScreen.kt | 4 +- .../settings/dal/HiddenAccountsFeedFilter.kt | 2 +- .../settings/dal/HiddenWordsFeedFilter.kt | 2 +- .../settings/dal/SpammerAccountsFeedFilter.kt | 2 +- .../loggedIn/threadview/ThreadFeedView.kt | 2 +- .../threadview/dal/ThreadFeedFilter.kt | 2 +- .../datasources/ThreadFilterAssembler.kt | 2 +- .../FilterEventsInThreadForRoot.kt | 109 +- .../FilterMissingEventsForThread.kt | 7 +- .../ThreadEventLoaderSubAssembler.kt | 10 +- .../subassembies/ThreadFilterSubAssembler.kt | 10 +- .../ui/screen/loggedIn/video/VideoScreen.kt | 2 +- .../loggedIn/video/dal/VideoFeedFilter.kt | 4 +- ...ctureAndVideoByGeohash.kt => FeedBasis.kt} | 48 +- .../video/datasource/VideoFilterAssembler.kt | 7 +- .../FilterPictureAndVideoByFollows.kt | 64 - .../FilterPictureAndVideoByHashtag.kt | 72 - ...deoMixGeohashHashtagsFilterSubAssembler.kt | 79 - .../VideoOutboxEventsFilterSubAssembler.kt | 51 +- .../FilterPictureAndVideoByGeohash.kt | 86 + .../FilterPictureAndVideoByHashtag.kt | 89 + .../nip01Core/FilterPictureAndVideoGlobal.kt | 61 + .../FilterPictureAndVideoByAuthors.kt | 108 + .../FilterPictureAndVideoByFollows.kt | 56 + .../FilterPictureAndVideoByAllCommunities.kt | 117 + .../FilterPictureAndVideoByCommunity.kt | 124 + .../ammolite/relays/Constants.kt | 57 - .../ammolite/relays/NostrClient.kt | 475 --- .../vitorpamplona/ammolite/relays/Relay.kt | 274 -- .../ammolite/relays/RelayPool.kt | 311 -- .../relays/datasources/NostrDataSource.kt | 276 -- .../relays/datasources/Subscription.kt | 106 +- .../datasources/SubscriptionController.kt | 36 +- .../relays/datasources/SubscriptionSet.kt | 6 +- .../filters/{EOSETime.kt => MutableTime.kt} | 19 +- .../ammolite/relays/filters/NormalFilter.kt | 57 - .../filters/SinceAuthorPerRelayFilter.kt | 89 - .../relays/filters/SincePerRelayFilter.kt | 69 - .../quartz/benchmark/HintIndexerBenchmark.kt | 2 +- .../commons/base64Image/Base64Image.kt | 2 +- gradle/libs.versions.toml | 3 +- quartz/build.gradle | 1 - quartz/notebooks/Kind1Test.ipynb | 147 + .../quartz/nip03Timestamp/ots/OtsTest.kt | 4 +- .../com/vitorpamplona/quartz/EventFactory.kt | 10 +- .../audio/track/tags/ParticipantTag.kt | 10 +- .../edits/PrivateOutboxRelayListEvent.kt | 35 +- .../experimental/edits/tags/RelayTag.kt | 52 + .../ephemChat/chat/EphemeralChatEvent.kt | 9 +- .../experimental/ephemChat/chat/RoomId.kt | 7 +- .../ephemChat/chat/TagArrayBuilderExt.kt | 3 +- .../ephemChat/chat/tags/RelayTag.kt | 13 +- .../ephemChat/chat/tags/RoomTag.kt | 6 +- .../ephemChat/db/EphemeralRoom.kt | 3 +- .../ephemChat/db/EphemeralRoomCache.kt | 5 +- .../ephemChat/list/EphemeralChatListEvent.kt | 10 +- .../ephemChat/list/tags/RoomIdTag.kt | 16 +- .../experimental/forks/MarkedETagExt.kt | 5 +- .../InteractiveStoryReadingStateEvent.kt | 7 +- .../interactiveStories/tags/RootSceneTag.kt | 22 +- .../experimental/medical/FhirResourceEvent.kt | 3 +- .../profileGallery/TagArrayBuilderExt.kt | 3 +- .../quartz/nip01Core/core/AddressableEvent.kt | 3 +- .../nip01Core/core/BaseAddressableEvent.kt | 3 +- .../nip01Core/core/BaseReplaceableEvent.kt | 3 +- .../quartz/nip01Core/hints/EventHintBundle.kt | 12 +- .../quartz/nip01Core/hints/HintIndexer.kt | 36 +- .../nip01Core/hints/types/AddressHint.kt | 4 +- .../nip01Core/hints/types/EventIdHint.kt | 10 +- .../nip01Core/hints/types/PubKeyHint.kt | 3 +- .../nip01Core/relay/client/NostrClient.kt | 276 ++ .../client/acessories}/EventCollector.kt | 15 +- .../NostrClientSingleDownloadExt.kt | 97 + .../client/acessories}/RelayAuthenticator.kt | 13 +- .../RelayInsertConfirmationCollector.kt | 13 +- .../relay/client/acessories}/RelayLogger.kt | 19 +- .../relay/client/acessories}/RelayNotifier.kt | 13 +- .../client/listeners/IRelayClientListener.kt | 138 + .../listeners/RedirectRelayClientListener.kt | 94 + .../relay/client/pool/PoolEventOutbox.kt | 91 + .../client/pool/PoolEventOutboxRepository.kt | 92 + .../relay/client/pool/PoolSubscription.kt | 14 +- .../client/pool/PoolSubscriptionRepository.kt | 87 + .../relay/client/pool/RelayBasedFilter.kt | 43 + .../nip01Core/relay/client/pool/RelayPool.kt | 299 ++ .../relay/client/single/IRelayClient.kt | 60 + .../relay/{ => client/single}/Subscription.kt | 8 +- .../single/basic/BasicRelayClient.kt} | 360 +- .../client/single/simple/OutboxProtector.kt | 88 + .../client/single/simple/SimpleRelayClient.kt | 46 + .../relay/{ => client/stats}/RelayStat.kt | 2 +- .../relay/client/stats}/RelayStats.kt | 24 +- .../relay/normalizer/NormalizedRelayUrl.kt | 44 + .../relay/normalizer/RelayUrlNormalizer.kt | 90 + .../nip01Core/relay/sockets/WebSocket.kt | 4 +- .../relay/sockets/WebSocketListener.kt | 1 + .../relay/sockets/WebsocketBuilder.kt | 4 +- .../quartz/nip01Core/signers/NostrSigner.kt | 2 + .../nip01Core/signers/NostrSignerInternal.kt | 2 + .../nip01Core/tags/addressables/ATag.kt | 42 +- .../nip01Core/tags/addressables/Address.kt | 32 +- .../nip01Core/tags/addressables/EventExt.kt | 4 +- .../tags/addressables/TagArrayBuilderExt.kt | 1 + .../quartz/nip01Core/tags/events/ETag.kt | 28 +- .../nip01Core/tags/events/EventReference.kt | 3 +- .../nip01Core/tags/events/GenericETag.kt | 3 +- .../tags/events/TagArrayBuilderExt.kt | 1 + .../nip01Core/tags/hashtags/HashtagTag.kt | 10 + .../quartz/nip01Core/tags/people/PTag.kt | 21 +- .../tags/people/PubKeyReferenceTag.kt | 3 +- .../tags/people/TagArrayBuilderExt.kt | 3 +- .../nip02FollowList/ContactListEvent.kt | 19 +- .../quartz/nip02FollowList/tags/ContactTag.kt | 28 +- .../quartz/nip10Notes/BaseThreadedEvent.kt | 10 +- .../quartz/nip10Notes/tags/MarkedETag.kt | 103 +- .../quartz/nip10Notes/tags/ReplyBuilder.kt | 4 +- .../nip17Dm/messages/ChatMessageEvent.kt | 3 +- .../settings/ChatMessageRelayListEvent.kt | 32 +- .../quartz/nip17Dm/settings/tags/RelayTag.kt | 52 + .../quartz/nip18Reposts/GenericRepostEvent.kt | 5 +- .../quartz/nip18Reposts/RepostEvent.kt | 5 +- .../quartz/nip18Reposts/quotes/EntityExt.kt | 7 + .../nip18Reposts/quotes/QAddressableTag.kt | 21 +- .../quartz/nip18Reposts/quotes/QEventTag.kt | 17 +- .../quartz/nip18Reposts/quotes/QTag.kt | 24 +- .../quartz/nip19Bech32/ATagExt.kt | 9 +- .../quartz/nip19Bech32/EventExt.kt | 3 +- .../quartz/nip19Bech32/TlvBuilderExt.kt | 5 + .../quartz/nip19Bech32/entities/NAddress.kt | 31 +- .../quartz/nip19Bech32/entities/NEvent.kt | 32 +- .../quartz/nip19Bech32/entities/NProfile.kt | 10 +- .../quartz/nip19Bech32/entities/NSec.kt | 4 + .../quartz/nip19Bech32/tlv/TlvBuilder.kt | 9 + .../quartz/nip21UriScheme/EventExt.kt | 3 +- .../nip22Comments/TagArrayBuilderExt.kt | 13 +- .../nip22Comments/tags/ReplyAddressTag.kt | 21 +- .../nip22Comments/tags/ReplyAuthorTag.kt | 19 +- .../nip22Comments/tags/ReplyEventTag.kt | 16 +- .../nip22Comments/tags/RootAddressTag.kt | 21 +- .../nip22Comments/tags/RootAuthorTag.kt | 24 +- .../quartz/nip22Comments/tags/RootEventTag.kt | 19 +- .../nip23LongContent/LongTextNoteEvent.kt | 3 +- .../admin/ChannelCreateEvent.kt | 22 +- .../admin/ChannelMetadataEvent.kt | 27 +- .../nip28PublicChat/base/ChannelData.kt | 24 +- .../nip28PublicChat/list/ChannelListEvent.kt | 10 +- .../list/TagArrayBuilderExt.kt | 3 +- .../nip34Git/repository/GitRepositoryEvent.kt | 3 +- .../ContentWarningTag.kt | 18 +- .../TagArrayBuilderExt.kt | 2 + .../nip36SensitiveContent/TagArrayExt.kt | 11 +- .../quartz/nip37Drafts/DraftEvent.kt | 20 +- .../quartz/nip40Expiration/EventExt.kt | 12 +- .../quartz/nip40Expiration/ExpirationTag.kt | 45 + .../nip40Expiration/TagArrayBuilderExt.kt | 26 + .../quartz/nip40Expiration/TagArrayExt.kt | 36 + .../quartz/nip42RelayAuth/RelayAuthEvent.kt | 23 +- .../nip42RelayAuth/tags/ChallengeTag.kt | 41 + .../quartz/nip42RelayAuth/tags/RelayTag.kt | 52 + .../nip47WalletConnect/Nip47WalletConnect.kt | 22 +- .../nip50Search/SearchRelayListEvent.kt | 27 +- .../quartz/nip50Search/tags/RelayTag.kt | 52 + .../quartz/nip51Lists/MuteListEvent.kt | 22 +- .../quartz/nip51Lists/PeopleListEvent.kt | 16 + .../quartz/nip51Lists/RelaySetEvent.kt | 4 +- .../nip51Lists/interests/HashtagListEvent.kt | 218 ++ .../interests/TagArrayBuilderExt.kt | 26 + .../nip51Lists/locations/GeohashListEvent.kt | 219 ++ .../locations/TagArrayBuilderExt.kt | 26 + .../quartz/nip51Lists/tags/RelayTag.kt | 52 + .../streaming/LiveActivitiesEvent.kt | 25 +- .../streaming/tags/ParticipantTag.kt | 28 +- .../streaming/tags/RelayListTag.kt | 20 +- .../quartz/nip54Wiki/WikiNoteEvent.kt | 3 +- .../nip55AndroidSigner/NostrSignerExternal.kt | 2 + .../quartz/nip56Reports/ReportEvent.kt | 20 +- .../quartz/nip57Zaps/LnZapEvent.kt | 21 +- .../quartz/nip57Zaps/LnZapPrivateEvent.kt | 20 +- .../quartz/nip57Zaps/LnZapRequestEvent.kt | 20 +- .../quartz/nip57Zaps/splits/ZapSplitSetup.kt | 3 +- .../nip57Zaps/splits/ZapSplitSetupParser.kt | 5 +- .../splits/ZapSplitSetupSerializer.kt | 4 +- .../quartz/nip58Badges/BadgeAwardEvent.kt | 20 +- .../quartz/nip58Badges/BadgeProfilesEvent.kt | 18 +- .../AdvertisedRelayListEvent.kt | 97 +- .../RelayListRecommendationProcessor.kt | 56 +- .../nip65RelayList/RelayUrlFormatter.kt | 83 - .../tags/AdvertisedRelayInfoTag.kt | 125 + .../nip72ModCommunities/CommunityListEvent.kt | 279 -- .../approval/CommunityPostApprovalEvent.kt | 24 +- .../definition/CommunityDefinitionEvent.kt | 28 +- .../definition/tags/ModeratorTag.kt | 28 +- .../definition/tags/RelayTag.kt | 24 +- .../follow/CommunityListEvent.kt | 272 ++ .../follow/TagArrayBuilderExt.kt | 31 + .../quartz/nip75ZapGoals/GoalEvent.kt | 20 +- .../quartz/nip84Highlights/HighlightEvent.kt | 23 +- .../nip89AppHandlers/clientTag/ClientTag.kt | 77 + .../nip89AppHandlers/clientTag/EventExt.kt | 26 + .../clientTag/TagArrayBuilderExt.kt} | 21 +- .../nip89AppHandlers/clientTag/TagArrayExt.kt | 25 + .../recommendation/AppRecommendationEvent.kt | 9 +- .../recommendation/TagArrayBuilderExt.kt | 5 +- .../recommendation/tags/RecommendationTag.kt | 30 +- .../NIP90ContentDiscoveryRequestEvent.kt | 5 +- .../BlossomAuthorizationEvent.kt | 2 +- .../BlossomServersEvent.kt | 2 +- .../vitorpamplona/quartz/utils/LargeCache.kt | 17 + .../quartz/utils/MapOfSetBuilder.kt | 73 + .../quartz/utils}/ParallelUtils.kt | 2 +- .../quartz/utils/RandomInstance.kt | 7 + quartz/src/test/java/android/util/Log.java | 35 + .../quartz/nip01Core/hints/HintIndexerTest.kt | 15 +- .../nip01Core/relay/RelayUrlFormatterTest.kt | 47 + .../quartz/nip19Bech32/NIP19ParserTest.kt | 39 +- .../RelayListRecommendationProcessorTest.kt | 31 +- 624 files changed, 19405 insertions(+), 11339 deletions(-) create mode 100644 .idea/deviceManager.xml create mode 100644 .idea/kotlinNotebook.xml delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/localRelays/LocalRelayListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/AccountOutboxRelayState.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/{service/relays/RelayManager.kt => model/nip01UserMetadata/NotificationInboxRelayState.kt} (61%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxRelays.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmInboxRelayState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip18Reposts/RepostAction.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip25Reactions/ReactionAction.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip38UserStatuses/UserStatusAction.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip50Search/SearchRelayListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockPeopleListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/GeohashListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HashtagListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/MuteListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip56Reports/ReportAction.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/OutboxRelaySetState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip96FileStorage/FileStorageServerListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineRelayListsState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedServerListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/TrustedRelayListsState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/CommunityRelayLoader.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/{service/relays/RelayServiceStatus.kt => model/topNavFeeds/IFeedFlowsType.kt} (78%) rename ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/IPerRelayFilter.kt => amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavFilter.kt (72%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/MergedTopFeedAuthorListsState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/{ui/screen/loggedIn/relays/kind3/Kind3BasicRelaySetupInfo.kt => model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavPerRelayFilter.kt} (60%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavPerRelayFilterSet.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsFeedFlow.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeFeedFlow.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt rename ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayBriefInfoCache.kt => amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalFeedFlow.kt (61%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavFilter.kt rename ammolite/src/main/java/com/vitorpamplona/ammolite/relays/TypedFilter.kt => amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilter.kt (77%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavFilter.kt rename ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelaySetupInfo.kt => amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilter.kt (74%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/NoteFeedFlow.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavPerRelayFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavPerRelayFilterSet.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavPerRelayFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavPerRelayFilterSet.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownFeedFlow.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/{ui/screen/loggedIn/relays/recommendations/Kind3RelayProposalSetupInfo.kt => model/topNavFeeds/unknown/UnknownTopNavFilter.kt} (62%) rename quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayState.kt => amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt (83%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluation.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelaySettings.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/{relays/RelayService.kt => okhttp/ProxySettingsAnchor.kt} (57%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/BottomBarRoute.kt rename ammolite/src/main/java/com/vitorpamplona/ammolite/relays/MutableSubscriptionCache.kt => amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NormalizedRelayUrlSerializer.kt (57%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToAndFromMe.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/{DiscoveryFollowsDiscoverySubAssembler.kt => DiscoveryFollowsDiscoverySubAssembler1.kt} (62%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler2.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/{home/datasource/nip65Follows/HomeOutboxUsersEoseManager.kt => discover/datasource/DiscoveryFollowsDiscoverySubAssembler3.kt} (60%) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/MixGeohashHashtagsDiscoverySubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/{nip72Communities/subassemblies/FilterCommunityPostsByHashtag.kt => nip28Chats/subassemblies/FilterPublicChatsGlobal.kt} (54%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/{service/relayClient/reqCommand/event/watchers/FilterQuotesToNotes.kt => ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt} (54%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/{home/datasource/nip65Follows/FilterUserMetadataByFollows.kt => discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt} (50%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/{FilterCommunityPostsByGeohash.kt => FilterCommunitiesGlobal.kt} (57%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/mixGeohashHashtagsCommunities/MixGeohashHashtagsCommunityEoseManager.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/{nip65Follows/FilterHomePostsByFollows.kt => nip01Core/FilterHomePostsByGlobal.kt} (51%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Geohash/FilterPostsByGeohashes.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Geohash/GeohashEventsEoseManager.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Hashtags/FilterHomePostsByHashtags.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Hashtags/HashtagEventsFilterSubAssembler.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/{FilterPostsByGeohashScopes.kt => FilterPostsByScopes.kt} (73%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAllFollows.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/CommunityEventsFilterSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsByAllCommunities.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListView.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListViewModel.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalDialog.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalRow.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/{subassemblies/FilterPictureAndVideoByGeohash.kt => FeedBasis.kt} (50%) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByFollows.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByHashtag.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoMixGeohashHashtagsFilterSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByFollows.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt delete mode 100644 ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Constants.kt delete mode 100644 ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt delete mode 100644 ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt delete mode 100644 ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt delete mode 100644 ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/NostrDataSource.kt rename ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/{EOSETime.kt => MutableTime.kt} (80%) delete mode 100644 ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/NormalFilter.kt delete mode 100644 ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt delete mode 100644 ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SincePerRelayFilter.kt create mode 100644 quartz/notebooks/Kind1Test.ipynb create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/tags/RelayTag.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt rename {ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources => quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories}/EventCollector.kt (80%) create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories/NostrClientSingleDownloadExt.kt rename {ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources => quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories}/RelayAuthenticator.kt (79%) rename {ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources => quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories}/RelayInsertConfirmationCollector.kt (81%) rename {ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources => quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories}/RelayLogger.kt (79%) rename {ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources => quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories}/RelayNotifier.kt (80%) create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt rename ammolite/src/main/java/com/vitorpamplona/ammolite/relays/SubscriptionCache.kt => quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscription.kt (77%) create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/IRelayClient.kt rename quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/{ => client/single}/Subscription.kt (86%) rename quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/{SimpleClientRelay.kt => client/single/basic/BasicRelayClient.kt} (54%) create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/OutboxProtector.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/SimpleRelayClient.kt rename quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/{ => client/stats}/RelayStat.kt (97%) rename {ammolite/src/main/java/com/vitorpamplona/ammolite/relays => quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/stats}/RelayStats.kt (74%) create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/tags/RelayTag.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/ExpirationTag.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayBuilderExt.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayExt.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/ChallengeTag.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/RelayTag.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/tags/RelayTag.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/interests/HashtagListEvent.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/interests/TagArrayBuilderExt.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/locations/GeohashListEvent.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/locations/TagArrayBuilderExt.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/RelayTag.kt delete mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayUrlFormatter.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/tags/AdvertisedRelayInfoTag.kt delete mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityListEvent.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/TagArrayBuilderExt.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/ClientTag.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt rename quartz/src/main/java/com/vitorpamplona/quartz/{nip01Core/relay/SubscriptionCollection.kt => nip89AppHandlers/clientTag/TagArrayBuilderExt.kt} (73%) create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayExt.kt rename quartz/src/main/java/com/vitorpamplona/quartz/{blossom => nipB7Blossom}/BlossomAuthorizationEvent.kt (98%) rename quartz/src/main/java/com/vitorpamplona/quartz/{blossom => nipB7Blossom}/BlossomServersEvent.kt (98%) create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/utils/MapOfSetBuilder.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst => quartz/src/main/java/com/vitorpamplona/quartz/utils}/ParallelUtils.kt (98%) create mode 100644 quartz/src/test/java/android/util/Log.java create mode 100644 quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlFormatterTest.kt diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 000000000..91f95584d --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/kotlinNotebook.xml b/.idea/kotlinNotebook.xml new file mode 100644 index 000000000..665e38dae --- /dev/null +++ b/.idea/kotlinNotebook.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index bb4493707..bdfaa5e06 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,5 +1,11 @@ + + + + diff --git a/README.md b/README.md index 97d6e99df..bab0a73aa 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ Lastly, the user's account information (private key/pub key) is stored in the An ## Setup Make sure to have the following pre-requisites installed: -1. Java 17+ +1. Java 21+ 2. Android Studio 3. Android 8.0+ Phone or Emulation setup diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 91b9e0ad5..fc3031dba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -37,17 +37,19 @@ import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket +import com.vitorpamplona.amethyst.service.okhttp.ProxySettingsAnchor 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.RelayProxyClientConnector import com.vitorpamplona.amethyst.service.relayClient.RelaySpeedLogger 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 +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope @@ -89,18 +91,26 @@ class Amethyst : Application() { scope = applicationIOScope, ) - // Connects the NostrClient class with okHttp - val factory = - OkHttpWebSocket.BuilderFactory { _, useProxy -> - okHttpClients.getHttpClient(useProxy) - } + val torProxySettingsAnchor = ProxySettingsAnchor() - // Provides a relay pool - val client: NostrClient = NostrClient(factory) + // Connects the NostrClient class with okHttp + val websocketBuilder = + OkHttpWebSocket.Builder { url -> + okHttpClients.getHttpClient(torProxySettingsAnchor.useProxy(url)) + } // Caches all events in Memory val cache: LocalCache = LocalCache + // Organizes cache clearing + val trimmingService = MemoryTrimmingService(cache) + + // Provides a relay pool + val client: NostrClient = NostrClient(websocketBuilder, applicationIOScope) + + // Watches for changes on Tor and Relay List Settings + val relayProxyClientConnector = RelayProxyClientConnector(torProxySettingsAnchor, okHttpClients, connManager, client, applicationIOScope) + // Verifies and inserts in the cache from all relays, all subscriptions val cacheClientConnector = CacheClientConnector(client, cache) @@ -112,15 +122,9 @@ class Amethyst : Application() { val logger = if (isDebug) RelaySpeedLogger(client) else null - // 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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt index 766ae8b77..7caf3ef17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt @@ -33,9 +33,9 @@ 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() }}") } + // Amethyst.instance.client + // .allSubscriptions() + // .forEach { Log.d("STATE DUMP", "${it.key} ${it.value.filters.joinToString { it.filter.toJson() }}") } val totalMemoryMb = Runtime.getRuntime().totalMemory() / (1024 * 1024) val freeMemoryMb = Runtime.getRuntime().freeMemory() / (1024 * 1024) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 5349ffe8f..b9c909df5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -27,16 +27,15 @@ import android.util.Log import androidx.compose.runtime.Immutable import androidx.core.content.edit import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.model.ALL_FOLLOWS import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS -import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.Settings import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow -import com.vitorpamplona.ammolite.relays.RelaySetupInfo import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.nip01Core.core.Event @@ -53,7 +52,10 @@ import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -82,7 +84,6 @@ private object PrefKeys { const val SAVED_ACCOUNTS = "all_saved_accounts" const val NOSTR_PRIVKEY = "nostr_privkey" const val NOSTR_PUBKEY = "nostr_pubkey" - const val RELAYS = "relays" const val LOCAL_RELAY_SERVERS = "localRelayServers" const val DEFAULT_FILE_SERVER = "defaultFileServer" const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" @@ -99,6 +100,9 @@ private object PrefKeys { const val LATEST_PRIVATE_HOME_RELAY_LIST = "latestPrivateHomeRelayList" const val LATEST_APP_SPECIFIC_DATA = "latestAppSpecificData" const val LATEST_CHANNEL_LIST = "latestChannelList" + const val LATEST_COMMUNITY_LIST = "latestCommunityList" + const val LATEST_HASHTAG_LIST = "latestHashtagList" + const val LATEST_GEOHASH_LIST = "latestGeohashList" const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" @@ -120,8 +124,8 @@ object LocalPreferences { private const val COMMA = "," private var currentAccount: String? = null - private var savedAccounts: MutableStateFlow?> = MutableStateFlow(null) - private var cachedAccounts: MutableMap = mutableMapOf() + private val savedAccounts: MutableStateFlow?> = MutableStateFlow(null) + private val cachedAccounts: MutableMap = mutableMapOf() suspend fun currentAccount(): String? { if (currentAccount == null) { @@ -249,7 +253,7 @@ object LocalPreferences { if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub" Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE) } else { - return Amethyst.instance.encryptedStorage(npub) + Amethyst.instance.encryptedStorage(npub) } } @@ -299,7 +303,6 @@ object LocalPreferences { settings.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) } } settings.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) } - putString(PrefKeys.RELAYS, EventMapper.mapper.writeValueAsString(settings.localRelays)) putString( PrefKeys.DEFAULT_FILE_SERVER, @@ -364,8 +367,8 @@ object LocalPreferences { remove(PrefKeys.LATEST_SEARCH_RELAY_LIST) } - if (settings.localRelayServers.isNotEmpty()) { - putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers) + if (settings.localRelayServers.value.isNotEmpty()) { + putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers.value) } else { remove(PrefKeys.LOCAL_RELAY_SERVERS) } @@ -406,6 +409,33 @@ object LocalPreferences { remove(PrefKeys.LATEST_CHANNEL_LIST) } + if (settings.backupCommunityList != null) { + putString( + PrefKeys.LATEST_COMMUNITY_LIST, + EventMapper.mapper.writeValueAsString(settings.backupCommunityList), + ) + } else { + remove(PrefKeys.LATEST_COMMUNITY_LIST) + } + + if (settings.backupHashtagList != null) { + putString( + PrefKeys.LATEST_HASHTAG_LIST, + EventMapper.mapper.writeValueAsString(settings.backupHashtagList), + ) + } else { + remove(PrefKeys.LATEST_HASHTAG_LIST) + } + + if (settings.backupGeohashList != null) { + putString( + PrefKeys.LATEST_HASHTAG_LIST, + EventMapper.mapper.writeValueAsString(settings.backupGeohashList), + ) + } else { + remove(PrefKeys.LATEST_HASHTAG_LIST) + } + if (settings.backupEphemeralChatList != null) { putString( PrefKeys.LATEST_EPHEMERAL_LIST, @@ -453,9 +483,8 @@ object LocalPreferences { prefs: SharedPreferences = encryptedPreferences(), ) { Log.d("LocalPreferences", "Saving to shared settings") - with(prefs.edit()) { + prefs.edit { putString(PrefKeys.SHARED_SETTINGS, EventMapper.mapper.writeValueAsString(sharedSettings)) - apply() } } @@ -514,7 +543,7 @@ object LocalPreferences { ?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null val defaultHomeFollowList = - getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS + getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: ALL_FOLLOWS val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS val defaultNotificationFollowList = @@ -522,8 +551,6 @@ object LocalPreferences { val defaultDiscoveryFollowList = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS - val localRelays = parseOrNull>(PrefKeys.RELAYS) ?: emptySet() - val zapPaymentRequestServer = parseOrNull(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) val defaultFileServer = parseOrNull(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0] @@ -538,8 +565,11 @@ object LocalPreferences { val latestMuteList = parseEventOrNull(PrefKeys.LATEST_MUTE_LIST) val latestPrivateHomeRelayList = parseEventOrNull(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST) val latestAppSpecificData = parseEventOrNull(PrefKeys.LATEST_APP_SPECIFIC_DATA) - val latestEphemeralList = parseEventOrNull(PrefKeys.LATEST_EPHEMERAL_LIST) val latestChannelList = parseEventOrNull(PrefKeys.LATEST_CHANNEL_LIST) + val latestCommunityList = parseEventOrNull(PrefKeys.LATEST_COMMUNITY_LIST) + val latestHashtagList = parseEventOrNull(PrefKeys.LATEST_HASHTAG_LIST) + val latestGeohashList = parseEventOrNull(PrefKeys.LATEST_GEOHASH_LIST) + val latestEphemeralList = parseEventOrNull(PrefKeys.LATEST_EPHEMERAL_LIST) val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) @@ -559,14 +589,13 @@ object LocalPreferences { keyPair = keyPair, transientAccount = false, externalSignerPackageName = externalSignerPackageName, - localRelays = localRelays, - localRelayServers = localRelayServers, + localRelayServers = MutableStateFlow(localRelayServers), defaultFileServer = defaultFileServer, defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList), defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList), defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList), defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList), - zapPaymentRequest = zapPaymentRequestServer, + zapPaymentRequest = zapPaymentRequestServer?.normalize(), hideDeleteRequestDialog = hideDeleteRequestDialog, hideBlockAlertDialog = hideBlockAlertDialog, hideNIP17WarningDialog = hideNIP17WarningDialog, @@ -579,6 +608,9 @@ object LocalPreferences { backupMuteList = latestMuteList, backupAppSpecificData = latestAppSpecificData, backupChannelList = latestChannelList, + backupCommunityList = latestCommunityList, + backupHashtagList = latestHashtagList, + backupGeohashList = latestGeohashList, backupEphemeralChatList = latestEphemeralList, torSettings = TorSettingsFlow.build(torSettings), lastReadPerRoute = MutableStateFlow(lastReadPerRoute), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt deleted file mode 100644 index bc464ebdc..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Copyright (c) 2025 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 - -import android.util.Log -import androidx.compose.runtime.Stable -import coil3.annotation.DelicateCoilApi -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.ammolite.relays.NostrClient -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -@Stable -class ServiceManager( - val client: NostrClient, - val scope: CoroutineScope, -) { - // to not open amber in a loop trying to use auth relays and registering for notifications - private var isStarted: Boolean = false - - private var account: Account? = null - - private var collectorJob: Job? = null - - private fun start(account: Account) { - this.account = account - start() - } - - @OptIn(DelicateCoilApi::class) - private fun start() { - Log.d("ServiceManager", "-- May Start (hasStarted: $isStarted) for account $account") - if (isStarted && account != null) { - Log.d("ServiceManager", "---- Restarting innactive relay Services with Tor: ${account?.settings?.torSettings?.torType?.value}") - client.reconnect() - return - } - Log.d("ServiceManager", "---- Starting Relay Services with Tor: ${account?.settings?.torSettings?.torType?.value}") - - val myAccount = account - - if (myAccount != null) { - val relaySet = myAccount.connectToRelaysWithProxy.value - client.reconnect(relaySet) - - collectorJob?.cancel() - collectorJob = null - collectorJob = - scope.launch { - myAccount.connectToRelaysWithProxy.collectLatest { - delay(500) - if (isStarted) { - client.reconnect(it, onlyIfChanged = true) - } - } - } - - isStarted = true - } - } - - private fun pause() { - Log.d("ServiceManager", "-- Pausing Relay Services") - - collectorJob?.cancel() - collectorJob = null - - client.reconnect(null) - isStarted = false - } - - fun cleanObservers() { - LocalCache.cleanObservers() - } - - // This method keeps the pause/start in a Syncronized block to - // avoid concurrent pauses and starts. - @Synchronized - fun forceRestart( - account: Account? = null, - start: Boolean = true, - pause: Boolean = true, - ) { - Log.d("ServiceManager", "-- Force Restart (start:$start) (pause:$pause) for $account") - if (pause) { - pause() - } - - if (start) { - if (account != null) { - start(account) - } else { - start() - } - } - } - - fun setAccountAndRestart(account: Account) { - forceRestart(account, true, true) - } - - fun forceRestart() { - forceRestart(null, true, true) - } - - fun justStartIfItHasAccount() { - if (account != null) { - forceRestart(null, true, false) - } - } - - fun pauseForGood() { - forceRestart(null, false, true) - } - - fun pauseAndLogOff() { - account = null - forceRestart(null, false, true) - } -} 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 f153af026..d4820c0a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -21,40 +21,55 @@ package com.vitorpamplona.amethyst.model import android.util.Log -import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import com.fasterxml.jackson.module.kotlin.readValue -import com.fonfon.kgeohash.GeoHash import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatListState +import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState +import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountOutboxRelayState +import com.vitorpamplona.amethyst.model.nip01UserMetadata.NotificationInboxRelayState +import com.vitorpamplona.amethyst.model.nip01UserMetadata.UserMetadataState +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListOutboxRelays +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowsPerOutboxRelay +import com.vitorpamplona.amethyst.model.nip17Dms.DmInboxRelayState +import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState +import com.vitorpamplona.amethyst.model.nip18Reposts.RepostAction +import com.vitorpamplona.amethyst.model.nip25Reactions.ReactionAction import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatListState import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState -import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.model.nip38UserStatuses.UserStatusAction +import com.vitorpamplona.amethyst.model.nip50Search.SearchRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.BlockPeopleListState +import com.vitorpamplona.amethyst.model.nip51Lists.GeohashListState +import com.vitorpamplona.amethyst.model.nip51Lists.HashtagListState +import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState +import com.vitorpamplona.amethyst.model.nip51Lists.MuteListState +import com.vitorpamplona.amethyst.model.nip56Reports.ReportAction +import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState +import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState +import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState +import com.vitorpamplona.amethyst.model.nip96FileStorage.FileStorageServerListState +import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState +import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState +import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineRelayListsState +import com.vitorpamplona.amethyst.model.serverList.MergedServerListState +import com.vitorpamplona.amethyst.model.serverList.TrustedRelayListsState +import com.vitorpamplona.amethyst.model.topNavFeeds.FeedTopNavFilterState +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.MergedTopFeedAuthorListsState +import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState +import com.vitorpamplona.amethyst.model.torState.TorRelayState 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 -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.tor.TorType -import com.vitorpamplona.ammolite.relays.Constants -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.Relay -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.ammolite.relays.RelaySetupInfo -import com.vitorpamplona.ammolite.relays.RelaySetupInfoToConnect -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.blossom.BlossomAuthorizationEvent -import com.vitorpamplona.quartz.blossom.BlossomServersEvent import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent -import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent -import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent @@ -80,13 +95,20 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.acessories.downloadFirstEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote -import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedATags +import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEventIds import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash @@ -97,9 +119,6 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.references.references -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip02FollowList.ReadWrite -import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent @@ -110,7 +129,6 @@ import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip17Dm.NIP17Factory -import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent @@ -125,29 +143,26 @@ 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.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +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.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis -import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip37Drafts.DraftBuilder import com.vitorpamplona.quartz.nip37Drafts.DraftEvent -import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.Response -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent @@ -157,17 +172,13 @@ import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureMeta import com.vitorpamplona.quartz.nip68Picture.pictureIMeta import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoMeta import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId -import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId -import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.imetas @@ -180,36 +191,34 @@ import com.vitorpamplona.quartz.nip94FileMetadata.magnet import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag -import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent -import com.vitorpamplona.quartz.utils.DualCase +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.utils.tryAndWait import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import org.czeal.rfc3986.URIReference import java.math.BigDecimal import java.util.Base64 import java.util.Locale -import kotlin.coroutines.cancellation.CancellationException +import kotlin.Boolean +import kotlin.collections.filter +import kotlin.collections.flatten +import kotlin.collections.ifEmpty +import kotlin.collections.map +import kotlin.collections.plus +import kotlin.collections.toSet import kotlin.coroutines.resume @OptIn(DelicateCoroutinesApi::class) @@ -217,905 +226,124 @@ import kotlin.coroutines.resume class Account( val settings: AccountSettings = AccountSettings(KeyPair()), val signer: NostrSigner = settings.createSigner(), + val geolocationFlow: StateFlow, + val cache: LocalCache, + val client: NostrClient, val scope: CoroutineScope, ) { - companion object { - const val APP_SPECIFIC_DATA_D_TAG = "AmethystSettings" - } - - var transientHiddenUsers: MutableStateFlow> = MutableStateFlow(setOf()) - - @Immutable - class LiveFollowList( - val authors: Set = emptySet(), - val authorsPlusMe: Set, - val hashtags: Set = emptySet(), - val geotags: Set = emptySet(), - val addresses: Set = emptySet(), - ) { - val geotagScopes: Set = geotags.mapTo(mutableSetOf()) { GeohashId.toScope(it) } - val hashtagScopes: Set = hashtags.mapTo(mutableSetOf()) { HashtagId.toScope(it) } - } - - class FeedsBaseFlows( - val listName: String, - val peopleList: StateFlow = MutableStateFlow(NoteState(Note(" "))), - val kind3: StateFlow = MutableStateFlow(null), - val location: StateFlow = MutableStateFlow(null), - ) - - val connectToRelaysFlow = - combineTransform( - getNIP65RelayListFlow(), - getDMRelayListFlow(), - getSearchRelayListFlow(), - getPrivateOutboxRelayListFlow(), - userProfile().flow().relays.stateFlow, - ) { nip65RelayList, dmRelayList, searchRelayList, privateOutBox, userProfile -> - checkNotInMainThread() - emit( - normalizeAndCombineRelayListsWithFallbacks( - kind3RelayList = kind3Relays(), - newDMRelayEvent = dmRelayList.note.event as? ChatMessageRelayListEvent, - searchRelayEvent = searchRelayList.note.event as? SearchRelayListEvent, - privateOutboxRelayEvent = privateOutBox.note.event as? PrivateOutboxRelayListEvent, - nip65RelayEvent = nip65RelayList.note.event as? AdvertisedRelayListEvent, - ).toTypedArray(), - ) - } - - private fun normalizeAndCombineRelayListsWithFallbacks( - kind3RelayList: Array? = null, - newDMRelayEvent: ChatMessageRelayListEvent? = null, - searchRelayEvent: SearchRelayListEvent? = null, - privateOutboxRelayEvent: PrivateOutboxRelayListEvent? = null, - nip65RelayEvent: AdvertisedRelayListEvent? = null, - localRelayList: Set? = null, - ) = normalizeAndCombineRelayLists( - baseRelaySet = kind3RelayList ?: convertLocalRelays(), - newDMRelayEvent = newDMRelayEvent ?: settings.backupDMRelayList, - searchRelayEvent = searchRelayEvent ?: settings.backupSearchRelayList, - privateOutboxRelayEvent = privateOutboxRelayEvent ?: settings.backupPrivateHomeRelayList, - nip65RelayEvent = nip65RelayEvent ?: settings.backupNIP65RelayList, - localRelayList = localRelayList ?: settings.localRelayServers, - ) - - private fun normalizeAndCombineRelayLists( - baseRelaySet: Array, - newDMRelayEvent: ChatMessageRelayListEvent?, - searchRelayEvent: SearchRelayListEvent?, - privateOutboxRelayEvent: PrivateOutboxRelayListEvent?, - nip65RelayEvent: AdvertisedRelayListEvent?, - localRelayList: Set, - ): List { - val newDMRelaySet = newDMRelayEvent?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() - val searchRelaySet = (searchRelayEvent?.relays() ?: DefaultSearchRelayList).map { RelayUrlFormatter.normalize(it) }.toSet() - val nip65RelaySet = - nip65RelayEvent?.relays()?.map { - AdvertisedRelayListEvent.AdvertisedRelayInfo( - RelayUrlFormatter.normalize(it.relayUrl), - it.type, - ) - } - val privateOutboxRelaySet = privateOutboxRelayEvent?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() - val localRelaySet = localRelayList.map { RelayUrlFormatter.normalize(it) }.toSet() - - return combineRelayLists( - baseRelaySet = baseRelaySet, - newDMRelaySet = newDMRelaySet, - searchRelaySet = searchRelaySet, - privateOutboxRelaySet = privateOutboxRelaySet, - nip65RelaySet = nip65RelaySet, - localRelaySet = localRelaySet, - ) - } - - private fun combineRelayLists( - baseRelaySet: Array, - newDMRelaySet: Set, - searchRelaySet: Set, - privateOutboxRelaySet: Set, - nip65RelaySet: List?, - localRelaySet: Set, - ): List { - // ------ - // DMs - // ------ - var mappedRelaySet = - baseRelaySet.map { - if (newDMRelaySet.contains(it.url)) { - RelaySetupInfo(it.url, true, true, it.feedTypes + FeedType.PRIVATE_DMS) - } else { - it - } - } - - newDMRelaySet.forEach { newUrl -> - if (mappedRelaySet.none { it.url == newUrl }) { - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newUrl, - true, - true, - setOf( - FeedType.PRIVATE_DMS, - ), - ) - } - } - - // ------ - // SEARCH - // ------ - - mappedRelaySet = - mappedRelaySet.map { - if (searchRelaySet.contains(it.url)) { - RelaySetupInfo(it.url, true, it.write || false, it.feedTypes + FeedType.SEARCH) - } else { - it - } - } - - searchRelaySet.forEach { newUrl -> - if (mappedRelaySet.none { it.url == newUrl }) { - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newUrl, - true, - false, - setOf( - FeedType.SEARCH, - ), - ) - } - } - - // -------------- - // PRIVATE OUTBOX - // -------------- - - mappedRelaySet = - mappedRelaySet.map { - if (privateOutboxRelaySet.contains(it.url)) { - RelaySetupInfo(it.url, true, true, it.feedTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS)) - } else { - it - } - } - - privateOutboxRelaySet.forEach { newUrl -> - if (mappedRelaySet.none { it.url == newUrl }) { - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newUrl, - true, - true, - setOf( - FeedType.FOLLOWS, - FeedType.PUBLIC_CHATS, - FeedType.GLOBAL, - FeedType.PRIVATE_DMS, - ), - ) - } - } - - // -------------- - // Local Storage - // -------------- - - mappedRelaySet = - mappedRelaySet.map { - if (localRelaySet.contains(it.url)) { - RelaySetupInfo(it.url, true, true, it.feedTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS)) - } else { - it - } - } - - localRelaySet.forEach { newUrl -> - if (mappedRelaySet.none { it.url == newUrl }) { - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newUrl, - true, - true, - setOf( - FeedType.FOLLOWS, - FeedType.PUBLIC_CHATS, - FeedType.GLOBAL, - FeedType.PRIVATE_DMS, - ), - ) - } - } - - // -------------- - // NIP-65 Public Inbox/Outbox - // -------------- - - mappedRelaySet = - mappedRelaySet.map { relay -> - val nip65setup = nip65RelaySet?.firstOrNull { relay.url == it.relayUrl } - if (nip65setup != null) { - val write = nip65setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.BOTH || nip65setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.WRITE - - RelaySetupInfo( - relay.url, - true, - relay.write || write, - relay.feedTypes + - setOf( - FeedType.FOLLOWS, - FeedType.GLOBAL, - FeedType.PUBLIC_CHATS, - ), - ) - } else { - relay - } - } - - nip65RelaySet?.forEach { newNip65Setup -> - if (mappedRelaySet.none { it.url == newNip65Setup.relayUrl }) { - val write = newNip65Setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.BOTH || newNip65Setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.WRITE - - mappedRelaySet = mappedRelaySet + - RelaySetupInfo( - newNip65Setup.relayUrl, - true, - write, - setOf( - FeedType.FOLLOWS, - FeedType.PUBLIC_CHATS, - ), - ) - } - } - return mappedRelaySet - } - - val connectToRelays = - connectToRelaysFlow - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - normalizeAndCombineRelayListsWithFallbacks( - kind3Relays(), - getDMRelayList(), - getSearchRelayList(), - getPrivateOutboxRelayList(), - getNIP65RelayList(), - ).toTypedArray(), - ) - - val connectToRelaysWithProxy = - combineTransform( - connectToRelays, - settings.torSettings.torType, - settings.torSettings.onionRelaysViaTor, - settings.torSettings.trustedRelaysViaTor, - ) { relays, torType, useTorForOnionRelays, useTorForTrustedRelays -> - emit( - relays - .map { - RelaySetupInfoToConnect( - it.url, - torType != TorType.OFF && checkLocalHostOnionAndThen(it.url, useTorForOnionRelays, useTorForTrustedRelays), - it.read, - it.write, - it.feedTypes, - ) - }.toTypedArray(), - ) - }.flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - normalizeAndCombineRelayListsWithFallbacks( - kind3Relays(), - getDMRelayList(), - getSearchRelayList(), - getPrivateOutboxRelayList(), - getNIP65RelayList(), - ).map { - RelaySetupInfoToConnect( - it.url, - settings.torSettings.torType.value != TorType.OFF && - checkLocalHostOnionAndThen( - it.url, - settings.torSettings.onionRelaysViaTor.value, - settings.torSettings.trustedRelaysViaTor.value, - ), - it.read, - it.write, - it.feedTypes, - ) - }.toTypedArray(), - ) - - fun buildFollowLists(latestContactList: ContactListEvent?): LiveFollowList { - // makes sure the output include only valid p tags - val verifiedFollowingUsers = latestContactList?.verifiedFollowKeySet() ?: emptySet() - - return LiveFollowList( - authors = verifiedFollowingUsers, - authorsPlusMe = verifiedFollowingUsers + signer.pubKey, - hashtags = - latestContactList - ?.unverifiedFollowTagSet() - ?.map { it.lowercase() } - ?.toSet() ?: emptySet(), - geotags = - latestContactList - ?.geohashes() - ?.toSet() ?: emptySet(), - addresses = - latestContactList - ?.verifiedFollowAddressSet() - ?.toSet() ?: emptySet(), - ) - } - - fun normalizeDMRelayListWithBackup(note: Note): Set { - val event = note.event as? ChatMessageRelayListEvent ?: settings.backupDMRelayList - return event?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() - } - - val normalizedDmRelaySet = - getDMRelayListFlow() - .map { normalizeDMRelayListWithBackup(it.note) } - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - normalizeDMRelayListWithBackup(getDMRelayListNote()), - ) - - fun normalizePrivateOutboxRelayListWithBackup(note: Note): Set { - val event = note.event as? PrivateOutboxRelayListEvent ?: settings.backupPrivateHomeRelayList - return event?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() - } - - val normalizedPrivateOutBoxRelaySet = - getPrivateOutboxRelayListFlow() - .map { normalizePrivateOutboxRelayListWithBackup(it.note) } - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - normalizePrivateOutboxRelayListWithBackup(getPrivateOutboxRelayListNote()), - ) - - fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set { - val event = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList - return event?.writeRelays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet() - } - - val normalizedNIP65WriteRelayList = - getNIP65RelayListFlow() - .map { normalizeNIP65WriteRelayListWithBackup(it.note) } - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - normalizeNIP65WriteRelayListWithBackup(getNIP65RelayListNote()), - ) - - @OptIn(ExperimentalCoroutinesApi::class) - val liveKind3FollowsFlow: Flow = - userProfile().flow().follows.stateFlow.transformLatest { - emit(buildFollowLists(it.user.latestContactList)) - } - - val liveKind3Follows = - liveKind3FollowsFlow - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - buildFollowLists(userProfile().latestContactList ?: settings.backupContactList), - ) - - fun loadFlowsFor(listName: String): FeedsBaseFlows = - when (listName) { - GLOBAL_FOLLOWS -> FeedsBaseFlows(listName) - KIND3_FOLLOWS -> FeedsBaseFlows(listName, kind3 = liveKind3Follows) - AROUND_ME -> - FeedsBaseFlows( - listName, - location = Amethyst.instance.locationManager.geohashStateFlow, - ) - else -> { - val note = LocalCache.checkGetOrCreateAddressableNote(listName) - if (note != null) { - FeedsBaseFlows( - listName, - peopleList = - note - .flow() - .metadata.stateFlow, - ) - } else { - FeedsBaseFlows(listName) - } - } - } - - fun compute50kmLine(geoHash: GeoHash): List { - val hashes = mutableListOf() - - hashes.add(geoHash.toString()) - - var currentGeoHash = geoHash - repeat(5) { - currentGeoHash = currentGeoHash.westernNeighbour - hashes.add(currentGeoHash.toString()) - } - - currentGeoHash = geoHash - repeat(5) { - currentGeoHash = currentGeoHash.easternNeighbour - hashes.add(currentGeoHash.toString()) - } - - return hashes - } - - fun compute50kmRange(geoHash: GeoHash): List { - val hashes = mutableListOf() - - hashes.addAll(compute50kmLine(geoHash)) - - var currentGeoHash = geoHash - repeat(5) { - currentGeoHash = currentGeoHash.northernNeighbour - hashes.addAll(compute50kmLine(currentGeoHash)) - } - - currentGeoHash = geoHash - repeat(5) { - currentGeoHash = currentGeoHash.southernNeighbour - hashes.addAll(compute50kmLine(currentGeoHash)) - } - - return hashes - } - - suspend fun mapIntoFollowLists( - listName: String, - kind3: LiveFollowList?, - noteState: NoteState, - location: LocationState.LocationResult?, - ): LiveFollowList? = - if (listName == GLOBAL_FOLLOWS) { - null - } else if (listName == KIND3_FOLLOWS) { - kind3 - } else if (listName == AROUND_ME) { - val geohashResult = location ?: Amethyst.instance.locationManager.geohashStateFlow.value - if (geohashResult is LocationState.LocationResult.Success) { - // 2 neighbors deep = 25x25km - LiveFollowList( - authorsPlusMe = setOf(signer.pubKey), - geotags = compute50kmRange(geohashResult.geoHash).toSet(), - ) - } else { - LiveFollowList(authorsPlusMe = setOf(signer.pubKey)) - } - } else { - val noteEvent = noteState.note.event - if (noteEvent is GeneralListEvent) { - waitToDecrypt(noteEvent) ?: LiveFollowList(authorsPlusMe = setOf(signer.pubKey)) - } else if (noteEvent is FollowListEvent) { - LiveFollowList(authors = noteEvent.pubKeys().toSet(), authorsPlusMe = setOf(signer.pubKey) + noteEvent.pubKeys()) - } else { - LiveFollowList(authorsPlusMe = setOf(signer.pubKey)) - } - } - - @OptIn(ExperimentalCoroutinesApi::class) - fun combinePeopleListFlows(peopleListFollowsSource: Flow): Flow = - peopleListFollowsSource - .transformLatest { listName -> - val followList = loadFlowsFor(listName) - emitAll( - combine(followList.kind3, followList.peopleList, followList.location) { kind3, peopleList, location -> - mapIntoFollowLists(followList.listName, kind3, peopleList, location) - }, - ) - } - - val liveHomeFollowLists: StateFlow by lazy { - combinePeopleListFlows(settings.defaultHomeFollowList) - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - runBlocking { - loadAndCombineFlows(settings.defaultHomeFollowList.value) - }, - ) - } - - val liveServerList: StateFlow> by lazy { - combine(getFileServersListFlow(), getBlossomServersListFlow()) { nip96, blossom -> - mergeServerList(nip96.note.event as? FileServersEvent, blossom.note.event as? BlossomServersEvent) - }.flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - runBlocking { - mergeServerList(getFileServersList(), getBlossomServersList()) - }, - ) - } - - suspend fun loadAndCombineFlows(listName: String): LiveFollowList? { - val flows = loadFlowsFor(listName) - return mapIntoFollowLists( - flows.listName, - flows.kind3.value, - flows.peopleList.value, - flows.location.value, - ) - } - - /** - * filter onion and local host from write relays - * for each user pubkey, a list of valid relays. - */ - private fun assembleAuthorsPerWriteRelay( - userList: Map>, - hasOnionConnection: Boolean = false, - ): Map> { - checkNotInMainThread() - - val authorsPerRelayUrl = mutableMapOf>() - val relayUrlsPerAuthor = mutableMapOf>() - - userList.forEach { userWriteRelayListPair -> - userWriteRelayListPair.value.forEach { relayUrl -> - if (!RelayUrlFormatter.isLocalHost(relayUrl) && (hasOnionConnection || !RelayUrlFormatter.isOnion(relayUrl))) { - RelayUrlFormatter.normalizeOrNull(relayUrl)?.let { normRelayUrl -> - val userSet = authorsPerRelayUrl[normRelayUrl] - if (userSet != null) { - userSet.add(userWriteRelayListPair.key) - } else { - authorsPerRelayUrl[normRelayUrl] = mutableSetOf(userWriteRelayListPair.key) - } - - val relaySet = authorsPerRelayUrl[userWriteRelayListPair.key] - if (relaySet != null) { - relaySet.add(normRelayUrl) - } else { - relayUrlsPerAuthor[userWriteRelayListPair.key] = mutableSetOf(normRelayUrl) - } - } - } - } - } - - // for each relay, authors that only use this relay go first. - // then keeps order by pubkey asc - val comparator = compareByDescending { relayUrlsPerAuthor[it]?.size ?: 0 }.thenBy { it } - - return authorsPerRelayUrl.mapValues { - it.value.sortedWith(comparator) - } - } - - fun authorsPerRelay( - followsNIP65RelayLists: List, - defaultRelayList: List, - torType: TorType, - ): Map> = authorsPerRelay(followsNIP65RelayLists, defaultRelayList, torType != TorType.OFF) - - fun authorsPerRelay( - followsNIP65RelayLists: List, - defaultRelayList: List, - acceptOnion: Boolean, - ): Map> { - checkNotInMainThread() - - val defaultSet = defaultRelayList.toSet() - - return assembleAuthorsPerWriteRelay( - followsNIP65RelayLists - .mapNotNull - { - val author = (it as? AddressableNote)?.address?.pubKeyHex - val event = (it.event as? AdvertisedRelayListEvent) - - if (event != null) { - val authorWriteRelays = - event.writeRelays().map { - RelayUrlFormatter.normalize(it) - } - - val commonRelaysToMe = authorWriteRelays.filter { it in defaultSet } - if (commonRelaysToMe.isNotEmpty()) { - event.pubKey to commonRelaysToMe - } else { - event.pubKey to defaultRelayList - } - } else { - if (author != null) { - author to defaultRelayList - } else { - Log.e("Account", "This author should NEVER be null. Note: ${it.idHex}") - null - } - } - }.toMap(), - hasOnionConnection = acceptOnion, - ) - } - - @OptIn(ExperimentalCoroutinesApi::class) - val liveHomeFollowListAdvertizedRelayListFlow: Flow?> = - liveHomeFollowLists - .transformLatest { followList -> - if (followList != null) { - emitAll(combine(followList.authorsPlusMe.map { getNIP65RelayListFlow(it) }) { it }) - } else { - emit(null) - } - } - - val liveHomeListAuthorsPerRelayFlow: Flow>?> by lazy { - combineTransform(liveHomeFollowListAdvertizedRelayListFlow, connectToRelays, settings.torSettings.torType) { adverisedRelayList, existing, torStatus -> - if (adverisedRelayList != null) { - emit( - authorsPerRelay( - adverisedRelayList.map { it.note }, - existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, - torStatus, - ), - ) - } else { - emit(null) - } - } - } - - val liveHomeListAuthorsPerRelay: StateFlow>?> by lazy { - liveHomeListAuthorsPerRelayFlow - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - authorsPerRelay( - liveHomeFollowLists.value?.authorsPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), - connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, - settings.torSettings.torType.value, - ).ifEmpty { null }, - ) - } - - val liveNotificationFollowLists: StateFlow by lazy { - combinePeopleListFlows(settings.defaultNotificationFollowList) - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - runBlocking { - loadAndCombineFlows(settings.defaultNotificationFollowList.value) - }, - ) - } - - val liveStoriesFollowLists: StateFlow by lazy { - combinePeopleListFlows(settings.defaultStoriesFollowList) - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - runBlocking { - loadAndCombineFlows(settings.defaultStoriesFollowList.value) - }, - ) - } - - @OptIn(ExperimentalCoroutinesApi::class) - val liveStoriesFollowListAdvertizedRelayListFlow: Flow?> = - liveStoriesFollowLists - .transformLatest { followList -> - if (followList != null) { - emitAll(combine(followList.authorsPlusMe.map { getNIP65RelayListFlow(it) }) { it }) - } else { - emit(null) - } - } - - val liveStoriesListAuthorsPerRelayFlow: Flow>?> by lazy { - combineTransform(liveStoriesFollowListAdvertizedRelayListFlow, connectToRelays, settings.torSettings.torType) { adverisedRelayList, existing, torState -> - if (adverisedRelayList != null) { - emit( - authorsPerRelay( - adverisedRelayList.map { it.note }, - existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, - torState, - ), - ) - } else { - emit(null) - } - } - } - - val liveStoriesListAuthorsPerRelay: StateFlow>?> by lazy { - liveStoriesListAuthorsPerRelayFlow.flowOn(Dispatchers.Default).stateIn( + private var userProfileCache: User? = null + + fun userProfile(): User = userProfileCache ?: cache.getOrCreateUser(signer.pubKey).also { userProfileCache = it } + + val userMetadata = UserMetadataState(signer, cache, scope, settings) + + val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings) + val dmRelayList = DmRelayListState(signer, cache, scope, settings) + val searchRelayList = SearchRelayListState(signer, cache, scope, settings) + val privateStorageRelayList = PrivateStorageRelayListState(signer, cache, scope, settings) + val localRelayList = LocalRelayListState(signer, cache, scope, settings) + + val kind3FollowList = FollowListState(signer, cache, scope, settings) + val ephemeralChatList = EphemeralChatListState(signer, cache, scope, settings) + val publicChatList = PublicChatListState(signer, cache, scope, settings) + val communityList = CommunityListState(signer, cache, scope, settings) + val hashtagList = HashtagListState(signer, cache, scope, settings) + val geohashList = GeohashListState(signer, cache, scope, settings) + + val muteList = MuteListState(signer, cache, scope, settings) + val blockPeopleList = BlockPeopleListState(signer, cache, scope) + val hiddenUsers = HiddenUsersState(muteList.flow, blockPeopleList.flow, scope, settings) + + val emoji = EmojiPackState(signer, cache, scope) + + val appSpecific = AppSpecificState(signer, cache, scope, settings) + + val blossomServers = BlossomServerListState(signer, cache, scope, settings) + val fileStorageServers = FileStorageServerListState(signer, cache, scope, settings) + val serverLists = MergedServerListState(fileStorageServers.fileServers, blossomServers.fileServers, scope) + + // Relay settings + val outboxRelays = AccountOutboxRelayState(nip65RelayList, privateStorageRelayList, localRelayList, scope) + val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope) + val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope) + + val trustedRelays = TrustedRelayListsState(nip65RelayList, privateStorageRelayList, localRelayList, dmRelayList, searchRelayList, scope) + + // Follows Relays + val followOutboxes = FollowListOutboxRelays(kind3FollowList, cache, scope) + val followPlusAllMine = MergedFollowPlusMineRelayListsState(followOutboxes, nip65RelayList, privateStorageRelayList, localRelayList, scope) + + // keeps a cache of the outbox relays for each author + val followsPerRelay = FollowsPerOutboxRelay(kind3FollowList, cache, scope).flow + + // Merges all follow lists to create a single All Follows feed. + val allFollows = MergedFollowListsState(kind3FollowList, hashtagList, geohashList, communityList, scope) + + // App-ready Feeds + val liveHomeFollowLists: StateFlow = + FeedTopNavFilterState( + feedFilterListName = settings.defaultHomeFollowList, + allFollows = allFollows.flow, + locationFlow = geolocationFlow, + followsRelays = followPlusAllMine.flow, + signer = signer, + scope = scope, + ).flow + + val liveHomeFollowListsPerRelay = OutboxLoaderState(liveHomeFollowLists, cache, scope).flow + + val liveStoriesFollowLists: StateFlow = + FeedTopNavFilterState( + feedFilterListName = settings.defaultStoriesFollowList, + allFollows = allFollows.flow, + locationFlow = geolocationFlow, + followsRelays = followPlusAllMine.flow, + signer = signer, + scope = scope, + ).flow + + val liveStoriesFollowListsPerRelay = OutboxLoaderState(liveStoriesFollowLists, cache, scope).flow + + val liveDiscoveryFollowLists: StateFlow = + FeedTopNavFilterState( + feedFilterListName = settings.defaultDiscoveryFollowList, + allFollows = allFollows.flow, + locationFlow = geolocationFlow, + followsRelays = followPlusAllMine.flow, + signer = signer, + scope = scope, + ).flow + + val liveDiscoveryFollowListsPerRelay = OutboxLoaderState(liveDiscoveryFollowLists, cache, scope).flow + + val liveNotificationFollowLists: StateFlow = + FeedTopNavFilterState( + feedFilterListName = settings.defaultNotificationFollowList, + allFollows = allFollows.flow, + locationFlow = geolocationFlow, + followsRelays = followPlusAllMine.flow, + signer = signer, + scope = scope, + ).flow + + val liveNotificationFollowListsPerRelay = OutboxLoaderState(liveNotificationFollowLists, cache, scope).flow + + val mergedTopFeedAuthorLists = + MergedTopFeedAuthorListsState( + liveHomeFollowListsPerRelay, + liveStoriesFollowListsPerRelay, + liveDiscoveryFollowListsPerRelay, + liveNotificationFollowListsPerRelay, scope, - SharingStarted.Eagerly, - authorsPerRelay( - liveStoriesFollowLists.value?.authorsPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), - connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }, - settings.torSettings.torType.value, - ).ifEmpty { null }, - ) - } + ).flow - val liveDiscoveryFollowLists: StateFlow by lazy { - combinePeopleListFlows(settings.defaultDiscoveryFollowList) - .flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - runBlocking { - loadAndCombineFlows(settings.defaultDiscoveryFollowList.value) - }, - ) - } - - @OptIn(ExperimentalCoroutinesApi::class) - val liveDiscoveryFollowListAdvertizedRelayListFlow: Flow?> = - liveDiscoveryFollowLists - .transformLatest { followList -> - if (followList != null) { - emitAll(combine(followList.authorsPlusMe.map { getNIP65RelayListFlow(it) }) { it }) - } else { - emit(null) - } - } - - val liveDiscoveryListAuthorsPerRelayFlow: Flow>?> by lazy { - combineTransform(liveDiscoveryFollowListAdvertizedRelayListFlow, connectToRelays, settings.torSettings.torType) { adverisedRelayList, existing, torState -> - if (adverisedRelayList != null) { - emit( - authorsPerRelay( - adverisedRelayList.map { it.note }, - existing.filter { it.read }.map { it.url }, - torState, - ), - ) - } else { - emit(null) - } - } - } - - val liveDiscoveryListAuthorsPerRelay: StateFlow>?> by lazy { - liveDiscoveryListAuthorsPerRelayFlow.flowOn(Dispatchers.Default).stateIn( - scope, - SharingStarted.Eagerly, - authorsPerRelay( - liveDiscoveryFollowLists.value?.authorsPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(), - connectToRelays.value.filter { it.read }.map { it.url }, - settings.torSettings.torType.value, - ).ifEmpty { null }, - ) - } - - private fun decryptLiveFollows( - listEvent: GeneralListEvent, - onReady: (LiveFollowList) -> Unit, - ) { - listEvent.privateTags(signer) { privateTagList -> - val users = (listEvent.taggedUserIds() + listEvent.filterUsers(privateTagList)).toSet() - onReady( - LiveFollowList( - authors = users, - authorsPlusMe = users + userProfile().pubkeyHex, - hashtags = (listEvent.hashtags() + listEvent.filterHashtags(privateTagList)).toSet(), - geotags = (listEvent.geohashes() + listEvent.filterGeohashes(privateTagList)).toSet(), - addresses = - (listEvent.taggedATags() + listEvent.filterATags(privateTagList)) - .map { it.toTag() } - .toSet(), - ), - ) - } - } + val torRelayState = TorRelayState(trustedRelays, dmRelayList, settings, scope) fun decryptPeopleList( event: GeneralListEvent, onReady: (Array>) -> Unit, ) = event.privateTags(signer, onReady) - suspend fun waitToDecrypt(peopleListFollows: GeneralListEvent): LiveFollowList? = - tryAndWait { continuation -> - decryptLiveFollows(peopleListFollows) { - continuation.resume(it) - } - } - - @Immutable - class LiveHiddenUsers( - val hiddenUsers: Set, - val spammers: Set, - val hiddenWords: Set, - val showSensitiveContent: Boolean?, - ) { - // speeds up isHidden calculations - val hiddenUsersHashCodes = hiddenUsers.mapTo(HashSet()) { it.hashCode() } - val spammersHashCodes = spammers.mapTo(HashSet()) { it.hashCode() } - val hiddenWordsCase = hiddenWords.map { DualCase(it.lowercase(), it.uppercase()) } - } - - suspend fun decryptPeopleList(event: PeopleListEvent?): PeopleListEvent.UsersAndWords { - if (event == null || !isWriteable()) return PeopleListEvent.UsersAndWords() - - return tryAndWait { continuation -> - event.publicAndPrivateUsersAndWords(signer) { - continuation.resume(it) - } - } ?: PeopleListEvent.UsersAndWords() - } - - suspend fun decryptMuteList(event: MuteListEvent?): PeopleListEvent.UsersAndWords { - if (event == null || !isWriteable()) return PeopleListEvent.UsersAndWords() - - return tryAndWait { continuation -> - event.publicAndPrivateUsersAndWords(signer) { - continuation.resume(it) - } - } ?: PeopleListEvent.UsersAndWords() - } - - suspend fun assembleLiveHiddenUsers( - blockList: Note, - muteList: Note, - transientHiddenUsers: Set, - showSensitiveContent: Boolean?, - ): LiveHiddenUsers { - val resultBlockList = decryptPeopleList(blockList.event as? PeopleListEvent) - val resultMuteList = decryptMuteList(muteList.event as? MuteListEvent) - - return LiveHiddenUsers( - hiddenUsers = resultBlockList.users + resultMuteList.users, - hiddenWords = resultBlockList.words + resultMuteList.words, - spammers = transientHiddenUsers, - showSensitiveContent = showSensitiveContent, - ) - } - - val flowHiddenUsers: StateFlow by lazy { - combineTransform( - getBlockListNote().flow().metadata.stateFlow, - getMuteListNote().flow().metadata.stateFlow, - transientHiddenUsers, - settings.syncedSettings.security.showSensitiveContent, - ) { blockList, muteList, transientHiddenUsers, showSensitiveContent -> - checkNotInMainThread() - emit(assembleLiveHiddenUsers(blockList.note, muteList.note, transientHiddenUsers, showSensitiveContent)) - }.flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - runBlocking { - assembleLiveHiddenUsers( - getBlockListNote(), - getMuteListNote(), - transientHiddenUsers.value, - settings.syncedSettings.security.showSensitiveContent.value, - ) - }, - ) - } - @OptIn(FlowPreview::class) val decryptBookmarks: Flow by lazy { userProfile() @@ -1138,14 +366,6 @@ class Account( .flowOn(Dispatchers.Default) } - val emoji = EmojiPackState(signer, LocalCache, scope) - val ephemeralChatList = EphemeralChatListState(signer, LocalCache, scope) - val publicChatList = PublicChatListState(signer, LocalCache, scope) - - private var userProfileCache: User? = null - - fun userProfile(): User = userProfileCache ?: LocalCache.getOrCreateUser(signer.pubKey).also { userProfileCache = it } - fun isWriteable(): Boolean = settings.isWriteable() fun updateWarnReports(warnReports: Boolean): Boolean { @@ -1159,9 +379,7 @@ class Account( fun updateFilterSpam(filterSpam: Boolean): Boolean { if (settings.updateFilterSpam(filterSpam)) { if (!settings.syncedSettings.security.filterSpamFromStrangers.value) { - transientHiddenUsers.update { - emptySet() - } + hiddenUsers.resetTransientUsers() } sendNewAppSpecificData() @@ -1185,7 +403,7 @@ class Account( fun updateZapAmounts( amountSet: List, selectedZapType: LnZapEvent.ZapType, - nip47Update: Nip47WalletConnect.Nip47URI?, + nip47Update: Nip47WalletConnect.Nip47URINorm?, ) { var changed = false @@ -1219,54 +437,12 @@ class Account( } private fun sendNewAppSpecificData() { - sendNewAppSpecificData(settings.syncedSettings.toInternal()) - } - - private fun sendNewAppSpecificData(toInternal: AccountSyncedSettingsInternal) { - signer.nip44Encrypt(EventMapper.mapper.writeValueAsString(toInternal), signer.pubKey) { encrypted -> - AppSpecificDataEvent.create( - dTag = APP_SPECIFIC_DATA_D_TAG, - description = encrypted, - otherTags = emptyArray(), - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } - - fun sendKind3RelayList(relays: Map) { if (!isWriteable()) return - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.updateRelayList( - earlierVersion = contactList, - relayUse = relays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - ContactListEvent.createFromScratch( - followUsers = listOf(), - followTags = listOf(), - followGeohashes = listOf(), - followCommunities = listOf(), - relayUse = relays, - signer = signer, - ) { - // Keep this local to avoid erasing a good contact list. - // Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } + appSpecific.saveNewAppSpecificData(::sendMyPublicAndPrivateOutbox) } - suspend fun countFollowersOf(pubkey: HexKey): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkey) ?: false } + suspend fun countFollowersOf(pubkey: HexKey): Int = cache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkey) ?: false } suspend fun followerCount(): Int = countFollowersOf(signer.pubKey) @@ -1286,48 +462,10 @@ class Account( ) { if (!isWriteable()) return - val latest = userProfile().latestMetadata - - val template = - if (latest != null) { - MetadataEvent.updateFromPast( - latest = latest, - name = name, - displayName = name, - picture = picture, - banner = banner, - website = website, - pronouns = pronouns, - about = about, - nip05 = nip05, - lnAddress = lnAddress, - lnURL = lnURL, - twitter = twitter, - mastodon = mastodon, - github = github, - ) - } else { - MetadataEvent.createNew( - name = name, - displayName = name, - picture = picture, - banner = banner, - website = website, - pronouns = pronouns, - about = about, - nip05 = nip05, - lnAddress = lnAddress, - lnURL = lnURL, - twitter = twitter, - mastodon = mastodon, - github = github, - ) - } - - signer.sign(template) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } + userMetadata.sendNewUserMetadata( + name, picture, banner, website, pronouns, about, nip05, lnAddress, lnURL, twitter, mastodon, github, + ::sendMyPublicAndPrivateOutbox, + ) } fun reactionTo( @@ -1347,74 +485,17 @@ class Account( suspend fun reactTo( note: Note, reaction: String, - ) { - if (!isWriteable()) return - - if (hasReacted(note, reaction)) { - // has already liked this note - return - } - - val noteEvent = note.event - if (noteEvent is NIP17Group) { - val users = noteEvent.groupMembers().toList() - - if (reaction.startsWith(":")) { - val emojiUrl = EmojiUrlTag.decode(reaction) - if (emojiUrl != null) { - note.toEventHint()?.let { - NIP17Factory().createReactionWithinGroup( - emojiUrl = emojiUrl, - originalNote = it, - to = users, - signer = signer, - ) { - broadcastPrivately(it) - } - } - - return - } - } - - note.toEventHint()?.let { - NIP17Factory().createReactionWithinGroup( - content = reaction, - originalNote = it, - to = users, - signer = signer, - ) { - broadcastPrivately(it) - } - } - return - } else { - if (reaction.startsWith(":")) { - val emojiUrl = EmojiUrlTag.decode(reaction) - if (emojiUrl != null) { - note.event?.let { - signer.sign( - ReactionEvent.build(emojiUrl, EventHintBundle(it, note.relayHintUrl())), - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - - return - } - } - - note.toEventHint()?.let { - signer.sign( - ReactionEvent.build(reaction, it), - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } - } + ) = ReactionAction.reactTo( + note, + reaction, + userProfile(), + signer, + onPublic = { + client.send(it, computeMyReactionToNote(note, it)) + cache.justConsumeMyOwnEvent(it) + }, + onPrivate = ::broadcastPrivately, + ) fun createZapRequestFor( note: Note, @@ -1422,15 +503,17 @@ class Account( message: String = "", zapType: LnZapEvent.ZapType, toUser: User?, - additionalRelays: Set? = null, + additionalRelays: Set? = null, onReady: (LnZapRequestEvent) -> Unit, ) { if (!isWriteable()) return + val relays = nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()) + note.event?.let { event -> LnZapRequestEvent.create( event, - relays = getReceivingRelays() + (additionalRelays ?: emptySet()), + relays = relays.mapTo(mutableSetOf()) { it.url }, signer, pollOption, message, @@ -1441,19 +524,6 @@ class Account( } } - fun getReceivingRelays(): Set = - getNIP65RelayList()?.readRelays()?.toSet() - ?: userProfile() - .latestContactList - ?.relays() - ?.filter { it.value.read } - ?.keys - ?.ifEmpty { null } - ?: settings.localRelays - .filter { it.read } - .map { it.url } - .toSet() - fun hasWalletConnectSetup(): Boolean = settings.zapPaymentRequest != null fun isNIP47Author(pubkeyHex: String?): Boolean = (getNIP47Signer().pubKey == pubkeyHex) @@ -1509,28 +579,21 @@ class Account( fromServiceHex = nip47.pubKeyHex, toUserHex = event.pubKey, replyingToHex = event.id, + relay = nip47.relayUri, ) - Amethyst.instance.sources.nwc - .subscribe(filter) + Amethyst.instance.sources.nwc.subscribe(filter) - LocalCache.consume(event, zappedNote, true) { it.response(signer) { onResponse(it) } } + GlobalScope.launch(Dispatchers.IO) { + delay(60000) // waits 1 minute to complete payment. + Amethyst.instance.sources.nwc.unsubscribe(filter) + } - Amethyst.instance.client.sendSingle( - signedEvent = event, - relayTemplate = - RelaySetupInfoToConnect( - url = nip47.relayUri, - forceProxy = shouldUseTorForTrustedRelays(), - read = true, - write = true, - feedTypes = setOf(FeedType.WALLET_CONNECT), - ), - onDone = { - Amethyst.instance.sources.nwc - .unsubscribe(filter) - }, - ) + cache.consume(event, zappedNote, true, nip47.relayUri) { + it.response(signer) { onResponse(it) } + } + + client.send(event, setOf(nip47.relayUri)) onSent() } @@ -1545,12 +608,7 @@ class Account( ) { LnZapRequestEvent.create( userPubKeyHex, - userProfile() - .latestContactList - ?.relays() - ?.keys - ?.ifEmpty { null } - ?: settings.localRelays.map { it.url }.toSet(), + nip65RelayList.inboxFlow.value.toSet(), signer, message, zapType, @@ -1562,39 +620,12 @@ class Account( note: Note, type: ReportType, content: String = "", - ) { - if (!isWriteable()) return - - if (note.hasReport(userProfile(), type)) { - // has already reported this note - return - } - - note.event?.let { - signer.sign(ReportEvent.build(it, type)) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } + ) = ReportAction.report(note, type, content, userProfile(), signer, ::sendMyPublicAndPrivateOutbox) suspend fun report( user: User, type: ReportType, - ) { - if (!isWriteable()) return - - if (user.hasReport(userProfile(), type)) { - // has already reported this note - return - } - - val template = ReportEvent.build(user.pubkeyHex, type) - signer.sign(template) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } + ) = ReportAction.report(user, type, userProfile(), signer, ::sendMyPublicAndPrivateOutbox) fun delete(note: Note) { delete(listOf(note)) @@ -1603,15 +634,20 @@ class Account( fun delete(notes: List) { if (!isWriteable()) return - val myNoteVersions = notes.filter { it.author == userProfile() }.mapNotNull { it.event as? Event } - if (myNoteVersions.isNotEmpty()) { + val myNotes = notes.filter { it.author == userProfile() && it.event != null } + if (myNotes.isNotEmpty()) { // chunks in 200 elements to avoid going over the 65KB limit for events. - myNoteVersions.chunked(200).forEach { chunkedList -> + myNotes.chunked(200).forEach { chunkedList -> signer.sign( - DeletionEvent.build(chunkedList), + DeletionEvent.build(chunkedList.mapNotNull { it.event }), ) { deletionEvent -> - Amethyst.instance.client.send(deletionEvent) - LocalCache.justConsumeMyOwnEvent(deletionEvent) + val myRelayList = outboxRelays.flow.value.toMutableSet() + chunkedList.forEach { + myRelayList.addAll(it.relays) + } + + client.send(deletionEvent, outboxRelays.flow.value + myRelayList) + cache.justConsumeMyOwnEvent(deletionEvent) } } } @@ -1661,52 +697,236 @@ class Account( } suspend fun boost(note: Note) { - if (!isWriteable()) return - val noteEvent = note.event ?: return - - if (note.hasBoostedInTheLast5Minutes(userProfile())) { - // has already bosted in the past 5mins - return + RepostAction.repost(note, signer) { + client.send(it, computeMyReactionToNote(note, it)) + cache.justConsumeMyOwnEvent(it) } + } - val noteHint = note.relayHintUrl() - val authorHint = note.author?.bestRelayHint() + fun computeMyReactionToNote( + note: Note, + reaction: Event, + ): Set { + val relaysItCameFrom = note.relays - val template = - if (noteEvent.kind == 1) { - RepostEvent.build(noteEvent, noteHint, authorHint) - } else { - GenericRepostEvent.build(noteEvent, noteHint, authorHint) + val inboxRelaysOfTheAuthorOfTheOriginalNote = + note.author?.inboxRelays() ?: note.author?.pubkeyHex?.let { + cache.relayHints.hintsForKey(it) + } ?: emptyList() + + val reactionOutBoxRelays = outboxRelays.flow.value + + val taggedUsers = reaction.taggedUserIds() + (note.event?.taggedUserIds() ?: emptyList()) + + val taggedUserInboxRelays = + taggedUsers.flatMapTo(mutableSetOf()) { pubkey -> + if (pubkey == userProfile().pubkeyHex) { + notificationRelays.flow.value + } else { + LocalCache.getUserIfExists(pubkey)?.inboxRelays()?.ifEmpty { null }?.toSet() + ?: cache.relayHints.hintsForKey(pubkey).toSet() + } } - signer.sign(template) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } + val isInChannel = note.channelHex() + val channelRelays = + if (isInChannel != null) { + val channel = LocalCache.checkGetOrCreateChannel(isInChannel) + channel?.relays() ?: emptySet() + } else { + emptySet() + } + + val replyRelays = + note.replyTo?.flatMapTo(mutableSetOf()) { + val existingRelays = it.relays.toSet() + + val replyToAuthor = it.author + + val replyAuthorRelays = + if (replyToAuthor != null) { + if (replyToAuthor == userProfile()) { + outboxRelays.flow.value + } else { + replyToAuthor.outboxRelays().ifEmpty { null }?.toSet() + ?: cache.relayHints.hintsForKey(replyToAuthor.pubkeyHex).ifEmpty { null }?.toSet() + ?: emptySet() + } + } else { + emptySet() + } + + existingRelays + replyAuthorRelays + } ?: emptySet() + + return reactionOutBoxRelays + + inboxRelaysOfTheAuthorOfTheOriginalNote + + taggedUserInboxRelays + + channelRelays + + replyRelays + + relaysItCameFrom + } + + fun computeRelayListToBroadcast(event: Event): Set { + val author = cache.getUserIfExists(event.pubKey) + + val authorOutboxRelays = + if (author != null) { + if (author == userProfile()) { + outboxRelays.flow.value + } else { + author.outboxRelays().ifEmpty { null }?.toSet() + ?: cache.relayHints.hintsForKey(author.pubkeyHex).ifEmpty { null }?.toSet() + ?: emptySet() + } + } else { + cache.relayHints.hintsForKey(event.pubKey).ifEmpty { null }?.toSet() + emptySet() + } + + val taggedUserInboxRelays = + event.taggedUserIds().flatMapTo(mutableSetOf()) { pubkey -> + if (pubkey == userProfile().pubkeyHex) { + notificationRelays.flow.value + } else { + LocalCache.getUserIfExists(pubkey)?.inboxRelays()?.ifEmpty { null }?.toSet() + ?: cache.relayHints.hintsForKey(pubkey).toSet() + } + } + + val isInChannel = + if ( + event is ChannelMessageEvent || + event is ChannelMetadataEvent || + event is ChannelCreateEvent || + event is LiveActivitiesChatMessageEvent || + event is LiveActivitiesEvent || + event is EphemeralChatEvent + ) { + (event as? ChannelMessageEvent)?.channelId() + ?: (event as? ChannelMetadataEvent)?.channelId() + ?: (event as? ChannelCreateEvent)?.id + ?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag() + ?: (event as? LiveActivitiesEvent)?.aTag()?.toTag() + ?: (event as? EphemeralChatEvent)?.roomId()?.toKey() + } else { + null + } + + val channelRelays = + if (isInChannel != null) { + val channel = LocalCache.checkGetOrCreateChannel(isInChannel) + channel?.relays() ?: emptySet() + } else { + emptySet() + } + + val replyRelays = + cache.computeReplyTo(event).flatMapTo(mutableSetOf()) { + val existingRelays = it.relays.toSet() + val replyToAuthor = it.author + + val replyAuthorRelays = + if (replyToAuthor != null) { + if (replyToAuthor == userProfile()) { + outboxRelays.flow.value + } else { + replyToAuthor.outboxRelays().ifEmpty { null }?.toSet() + ?: cache.relayHints.hintsForKey(replyToAuthor.pubkeyHex).ifEmpty { null }?.toSet() + ?: emptySet() + } + } else { + emptySet() + } + + existingRelays + replyAuthorRelays + } + + return authorOutboxRelays + taggedUserInboxRelays + channelRelays + replyRelays + } + + fun computeRelayListToBroadcast(note: Note): Set { + val author = note.author + + val authorOutboxRelays = + if (author != null) { + if (author == userProfile()) { + outboxRelays.flow.value + } else { + author.outboxRelays().ifEmpty { null }?.toSet() + ?: cache.relayHints.hintsForKey(author.pubkeyHex).ifEmpty { null }?.toSet() + ?: emptySet() + } + } else { + emptySet() + } + + val taggedUserInboxRelays = + note.event?.taggedUserIds()?.flatMapTo(mutableSetOf()) { pubkey -> + if (pubkey == userProfile().pubkeyHex) { + notificationRelays.flow.value + } else { + LocalCache.getUserIfExists(pubkey)?.inboxRelays()?.ifEmpty { null }?.toSet() + ?: cache.relayHints.hintsForKey(pubkey).toSet() + } + } ?: emptySet() + + val isInChannel = note.channelHex() + val channelRelays = + if (isInChannel != null) { + val channel = LocalCache.checkGetOrCreateChannel(isInChannel) + channel?.relays() ?: emptySet() + } else { + emptySet() + } + + val replyRelays = + note.replyTo?.flatMapTo(mutableSetOf()) { + val existingRelays = it.relays.toSet() + + val replyToAuthor = it.author + + val replyAuthorRelays = + if (replyToAuthor != null) { + if (replyToAuthor == userProfile()) { + outboxRelays.flow.value + } else { + replyToAuthor.outboxRelays().ifEmpty { null }?.toSet() + ?: cache.relayHints.hintsForKey(replyToAuthor.pubkeyHex).ifEmpty { null }?.toSet() + ?: emptySet() + } + } else { + emptySet() + } + + existingRelays + replyAuthorRelays + } ?: emptySet() + + return authorOutboxRelays + taggedUserInboxRelays + channelRelays + replyRelays } fun broadcast(note: Note) { note.event?.let { if (it is WrappedEvent && it.host != null) { // download the event and send it. - it.host?.let { - Amethyst.instance.client.sendFilterAndStopOnFirstResponse( + it.host?.let { host -> + client.downloadFirstEvent( filters = - listOf( - TypedFilter( - setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL), - SincePerRelayFilter( - ids = listOf(it.id), + note.relays.map { relay -> + RelayBasedFilter( + relay, + Filter( + ids = listOf(host.id), ), - ), - ), + ) + }, onResponse = { - Amethyst.instance.client.send(it) + client.send(it, computeRelayListToBroadcast(it)) }, ) } } else { - Amethyst.instance.client.send(it) + client.send(it, computeRelayListToBroadcast(note)) } } } @@ -1720,7 +940,7 @@ class Account( val otsState = OtsEvent.upgrade(Base64.getDecoder().decode(pair.value), pair.key, otsResolver) if (otsState != null) { - val hint = LocalCache.getNoteIfExists(pair.key)?.toEventHint() + val hint = cache.getNoteIfExists(pair.key)?.toEventHint() val template = if (hint != null) { @@ -1730,8 +950,8 @@ class Account( } signer.sign(template) { - LocalCache.justConsumeMyOwnEvent(it) - Amethyst.instance.client.send(it) + cache.justConsumeMyOwnEvent(it) + client.send(it, computeRelayListToBroadcast(it)) settings.pendingAttestations.update { it - pair.key @@ -1756,222 +976,33 @@ class Account( settings.addPendingAttestation(id, Base64.getEncoder().encodeToString(OtsEvent.stamp(id, otsResolver))) } - fun follow(user: User) { - if (!isWriteable()) return + fun follow(user: User) = kind3FollowList.follow(user, this::sendMyPublicAndPrivateOutbox) - val contactList = userProfile().latestContactList + fun unfollow(user: User) = kind3FollowList.unfollow(user, this::sendMyPublicAndPrivateOutbox) - if (contactList != null) { - ContactListEvent.followUser(contactList, user.pubkeyHex, signer) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - ContactListEvent.createFromScratch( - followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)), - followTags = emptyList(), - followGeohashes = emptyList(), - followCommunities = emptyList(), - relayUse = - Constants.defaultRelays.associate { - it.url to ReadWrite(it.read, it.write) - }, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } + fun follow(channel: PublicChatChannel) = publicChatList.follow(channel, ::sendToPrivateOutboxAndLocal) - fun follow(channel: PublicChatChannel) { - if (!isWriteable()) return + fun unfollow(channel: PublicChatChannel) = publicChatList.unfollow(channel, ::sendToPrivateOutboxAndLocal) - publicChatList.follow(channel) { - sendToPrivateOutboxAndLocal(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } + fun follow(channel: EphemeralChatChannel) = ephemeralChatList.follow(channel, ::sendToPrivateOutboxAndLocal) - fun unfollow(channel: PublicChatChannel) { - if (!isWriteable()) return + fun unfollow(channel: EphemeralChatChannel) = ephemeralChatList.unfollow(channel, ::sendToPrivateOutboxAndLocal) - publicChatList.unfollow(channel) { - sendToPrivateOutboxAndLocal(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } + fun follow(community: AddressableNote) = communityList.follow(community, ::sendToPrivateOutboxAndLocal) - fun follow(channel: EphemeralChatChannel) { - if (!isWriteable()) return + fun unfollow(community: AddressableNote) = communityList.unfollow(community, ::sendToPrivateOutboxAndLocal) - ephemeralChatList.follow(channel) { - sendToPrivateOutboxAndLocal(it) - LocalCache.justConsumeInner(it, RelayBriefInfoCache.get(channel.roomId.relayUrl), true) - } - } + fun followHashtag(tag: String) = hashtagList.follow(tag, ::sendMyPublicAndPrivateOutbox) - fun unfollow(channel: EphemeralChatChannel) { - if (!isWriteable()) return + fun unfollowHashtag(tag: String) = hashtagList.unfollow(tag, ::sendMyPublicAndPrivateOutbox) - ephemeralChatList.unfollow(channel) { - sendToPrivateOutboxAndLocal(it) - LocalCache.justConsumeInner(it, RelayBriefInfoCache.get(channel.roomId.relayUrl), true) - } - } + fun followGeohash(geohash: String) = geohashList.follow(geohash, ::sendMyPublicAndPrivateOutbox) - fun follow(community: AddressableNote) { - if (!isWriteable()) return + fun unfollowGeohash(geohash: String) = geohashList.unfollow(geohash, ::sendMyPublicAndPrivateOutbox) - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followAddressableEvent(contactList, community.toATag(), signer) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - val relays = - Constants.defaultRelays.associate { - it.url to ReadWrite(it.read, it.write) - } - ContactListEvent.createFromScratch( - followUsers = emptyList(), - followTags = emptyList(), - followGeohashes = emptyList(), - followCommunities = listOf(community.toATag()), - relayUse = relays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } - - fun followHashtag(tag: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followHashtag( - contactList, - tag, - signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - ContactListEvent.createFromScratch( - followUsers = emptyList(), - followTags = listOf(tag), - followGeohashes = emptyList(), - followCommunities = emptyList(), - relayUse = - Constants.defaultRelays.associate { - it.url to ReadWrite(it.read, it.write) - }, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } - - fun followGeohash(geohash: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null) { - ContactListEvent.followGeohash( - contactList, - geohash, - signer, - onReady = this::onNewEventCreated, - ) - } else { - ContactListEvent.createFromScratch( - followUsers = emptyList(), - followTags = emptyList(), - followGeohashes = listOf(geohash), - followCommunities = emptyList(), - relayUse = - Constants.defaultRelays.associate { - it.url to ReadWrite(it.read, it.write) - }, - signer = signer, - onReady = this::onNewEventCreated, - ) - } - } - - fun onNewEventCreated(event: Event) { - Amethyst.instance.client.send(event) - LocalCache.justConsumeMyOwnEvent(event) - } - - fun unfollow(user: User) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowUser( - contactList, - user.pubkeyHex, - signer, - onReady = this::onNewEventCreated, - ) - } - } - - suspend fun unfollowHashtag(tag: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowHashtag( - contactList, - tag, - signer, - onReady = this::onNewEventCreated, - ) - } - } - - suspend fun unfollowGeohash(geohash: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowGeohash( - contactList, - geohash, - signer, - onReady = this::onNewEventCreated, - ) - } - } - - suspend fun unfollow(community: AddressableNote) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - - if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowAddressableEvent( - contactList, - community.toATag(), - signer, - onReady = this::onNewEventCreated, - ) - } + fun sendMyPublicAndPrivateOutbox(event: Event) { + client.send(event, outboxRelays.flow.value) + cache.justConsumeMyOwnEvent(event) } fun createNip95( @@ -2007,68 +1038,47 @@ class Account( fun consumeAndSendNip95( data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, - relayList: List, + relayList: List, ): Note? { if (!isWriteable()) return null - Amethyst.instance.client.send(data, relayList = relayList) - LocalCache.justConsumeMyOwnEvent(data) + client.send(data, relayList = relayList.toSet()) + cache.justConsumeMyOwnEvent(data) - Amethyst.instance.client.send(signedEvent, relayList = relayList) - LocalCache.justConsumeMyOwnEvent(signedEvent) + client.send(signedEvent, relayList = relayList.toSet()) + cache.justConsumeMyOwnEvent(signedEvent) - return LocalCache.getNoteIfExists(signedEvent.id) + return cache.getNoteIfExists(signedEvent.id) } fun consumeNip95( data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, ): Note? { - LocalCache.justConsumeMyOwnEvent(data) - LocalCache.justConsumeMyOwnEvent(signedEvent) + cache.justConsumeMyOwnEvent(data) + cache.justConsumeMyOwnEvent(signedEvent) - return LocalCache.getNoteIfExists(signedEvent.id) + return cache.getNoteIfExists(signedEvent.id) } fun sendNip95( data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, - relayList: List, + relayList: Set, ) { - Amethyst.instance.client.send(data, relayList = relayList) - Amethyst.instance.client.send(signedEvent, relayList = relayList) - } - - fun sendNip95Privately( - data: FileStorageEvent, - signedEvent: FileStorageHeaderEvent, - relayList: List, - ) { - val connect = - relayList.map { - val normalizedUrl = RelayUrlFormatter.normalize(it) - RelaySetupInfoToConnect( - normalizedUrl, - shouldUseTorForClean(normalizedUrl), - true, - true, - setOf(FeedType.GLOBAL), - ) - } - - Amethyst.instance.client.sendPrivately(data, relayList = connect) - Amethyst.instance.client.sendPrivately(signedEvent, relayList = connect) + client.send(data, relayList = relayList) + client.send(signedEvent, relayList = relayList) } fun sendHeader( signedEvent: Event, - relayList: List, + relayList: Set, onReady: (Note) -> Unit, ) { - Amethyst.instance.client.send(signedEvent, relayList = relayList) - LocalCache.justConsumeMyOwnEvent(signedEvent) + client.send(signedEvent, relayList = relayList) + cache.justConsumeMyOwnEvent(signedEvent) - LocalCache.getNoteIfExists(signedEvent.id)?.let { onReady(it) } + cache.getNoteIfExists(signedEvent.id)?.let { onReady(it) } } fun createHeader( @@ -2104,7 +1114,7 @@ class Account( urlHeaderInfo: Map, caption: String?, contentWarningReason: String?, - relayList: List, + relayList: Set, onReady: (Note) -> Unit, ) { val iMetas = @@ -2148,7 +1158,7 @@ class Account( alt: String?, contentWarningReason: String?, originalHash: String? = null, - relayList: List, + relayList: Set, onReady: (Note) -> Unit, ) { if (!isWriteable()) return @@ -2221,9 +1231,79 @@ class Account( } } + fun signAndSendPrivately( + template: EventTemplate, + relayList: Set, + ) { + signer.sign(template) { + cache.justConsumeMyOwnEvent(it) + client.send(it, relayList) + } + } + + fun signAndSendPrivatelyOrBroadcast( + template: EventTemplate, + relayList: (T) -> List?, + onDone: (T) -> Unit = {}, + ) { + signer.sign(template) { + cache.justConsumeMyOwnEvent(it) + val relays = relayList(it) + if (relays != null && relays.isNotEmpty()) { + client.send(it, relays.toSet()) + } else { + client.send(it, computeRelayListToBroadcast(it)) + } + onDone(it) + } + } + + fun signAndSend( + template: EventTemplate, + relayList: Set, + broadcastNotes: Set, + ) { + signer.sign(template) { + cache.justConsumeMyOwnEvent(it) + client.send(it, relayList = relayList) + + broadcastNotes.forEach { it.event?.let { client.send(it, relayList = relayList) } } + } + } + fun signAndSend( draftTag: String?, template: EventTemplate, + relayList: Set, + broadcastNotes: List, + ) = signAndSend(draftTag, template, relayList, mapEntitiesToNotes(broadcastNotes).toSet()) + + fun signAndSend( + draftTag: String?, + template: EventTemplate, + relayList: Set, + broadcastNotes: Set, + ) { + if (draftTag != null) { + signer.assembleRumor(template) { rumor -> + DraftEvent.create(draftTag, rumor, emptyList(), signer) { draftEvent -> + sendDraftEvent(draftEvent) + } + } + } else { + signer.sign(template) { + cache.justConsumeMyOwnEvent(it) + client.send(it, relayList = relayList) + + broadcastNotes.forEach { it.event?.let { client.send(it, relayList = relayList) } } + } + } + } + + fun signAndSendNIP04Message( + draftTag: String?, + template: EventTemplate, + relayList: Set, ) { if (draftTag != null) { if (template.content.isEmpty()) { @@ -2237,79 +1317,8 @@ class Account( } } else { signer.sign(template) { - LocalCache.justConsumeMyOwnEvent(it) - Amethyst.instance.client.send(it) - } - } - } - - fun signAndSendPrivately( - template: EventTemplate, - relayList: List, - onDone: (T) -> Unit = {}, - ) { - signer.sign(template) { - LocalCache.justConsumeMyOwnEvent(it) - Amethyst.instance.client.sendPrivately(it, relayList = convertRelayList(relayList)) - onDone(it) - } - } - - fun signAndSendPrivatelyOrBroadcast( - template: EventTemplate, - relayList: (T) -> List?, - onDone: (T) -> Unit = {}, - ) { - signer.sign(template) { - LocalCache.justConsumeMyOwnEvent(it) - val relays = relayList(it) - if (relays != null) { - Amethyst.instance.client.sendPrivately(it, relayList = convertRelayList(relays)) - } else { - Amethyst.instance.client.send(it) - } - onDone(it) - } - } - - fun signAndSend( - template: EventTemplate, - relayList: List, - broadcastNotes: Set, - ) { - signer.sign(template) { - LocalCache.justConsumeMyOwnEvent(it) - Amethyst.instance.client.send(it, relayList = relayList) - - broadcastNotes.forEach { it.event?.let { Amethyst.instance.client.send(it, relayList = relayList) } } - } - } - - fun signAndSend( - draftTag: String?, - template: EventTemplate, - relayList: List, - broadcastNotes: List, - ) = signAndSend(draftTag, template, relayList, mapEntitiesToNotes(broadcastNotes).toSet()) - - fun signAndSend( - draftTag: String?, - template: EventTemplate, - relayList: List, - broadcastNotes: Set, - ) { - if (draftTag != null) { - signer.assembleRumor(template) { rumor -> - DraftEvent.create(draftTag, rumor, emptyList(), signer) { draftEvent -> - sendDraftEvent(draftEvent) - } - } - } else { - signer.sign(template) { - LocalCache.justConsumeMyOwnEvent(it) - Amethyst.instance.client.send(it, relayList = relayList) - - broadcastNotes.forEach { it.event?.let { Amethyst.instance.client.send(it, relayList = relayList) } } + cache.justConsumeMyOwnEvent(it) + client.send(it, relayList) } } } @@ -2317,7 +1326,7 @@ class Account( fun signAndSendWithList( draftTag: String?, template: EventTemplate, - relayList: List, + relayList: Collection, broadcastNotes: Set, ) { if (draftTag != null) { @@ -2328,21 +1337,12 @@ class Account( } } else { signer.sign(template) { - val connect = - relayList.map { - val normalizedUrl = RelayUrlFormatter.normalize(it) - RelaySetupInfoToConnect( - normalizedUrl, - shouldUseTorForClean(normalizedUrl), - true, - true, - setOf(FeedType.GLOBAL), - ) - } + cache.justConsumeMyOwnEvent(it) - LocalCache.justConsumeMyOwnEvent(it) - Amethyst.instance.client.sendPrivately(it, relayList = connect) - broadcastNotes.forEach { it.event?.let { Amethyst.instance.client.sendPrivately(it, relayList = connect) } } + val relaySet = relayList.toSet() + + client.send(it, relayList = relaySet) + broadcastNotes.forEach { it.event?.let { client.send(it, relayList = relaySet) } } } } } @@ -2351,7 +1351,7 @@ class Account( draftTag: String?, template: EventTemplate, broadcastNotes: Set, - relayList: List, + relayList: Set, ) { if (!isWriteable()) return @@ -2370,23 +1370,12 @@ class Account( fun deleteDraft(draftTag: String) { val key = DraftEvent.createAddressTag(userProfile().pubkeyHex, draftTag) - LocalCache.getAddressableNoteIfExists(key)?.let { note -> + cache.getAddressableNoteIfExists(key)?.let { note -> val noteEvent = note.event if (noteEvent is DraftEvent) { noteEvent.createDeletedEvent(signer) { - Amethyst.instance.client.sendPrivately( - it, - note.relays.map { it.url }.map { - RelaySetupInfoToConnect( - it, - shouldUseTorForClean(it), - false, - true, - emptySet(), - ) - }, - ) - LocalCache.justConsumeMyOwnEvent(it) + client.send(it, outboxRelays.flow.value + note.relays) + cache.justConsumeMyOwnEvent(it) } } delete(note) @@ -2395,9 +1384,9 @@ class Account( suspend fun createInteractiveStoryReadingState( root: InteractiveStoryBaseEvent, - rootRelay: String?, + rootRelay: NormalizedRelayUrl?, readingScene: InteractiveStoryBaseEvent, - readingSceneRelay: String?, + readingSceneRelay: NormalizedRelayUrl?, ) { if (!isWriteable()) return @@ -2409,15 +1398,13 @@ class Account( currentSceneRelay = readingSceneRelay, ) - signer.sign(template) { - sendToPrivateOutboxAndLocal(it) - } + signer.sign(template, ::sendToPrivateOutboxAndLocal) } suspend fun updateInteractiveStoryReadingState( readingState: InteractiveStoryReadingStateEvent, readingScene: InteractiveStoryBaseEvent, - readingSceneRelay: String?, + readingSceneRelay: NormalizedRelayUrl?, ) { if (!isWriteable()) return @@ -2428,9 +1415,7 @@ class Account( currentSceneRelay = readingSceneRelay, ) - signer.sign(template) { - sendToPrivateOutboxAndLocal(it) - } + signer.sign(template, ::sendToPrivateOutboxAndLocal) } fun mapEntitiesToNotes(entities: List): List = @@ -2438,10 +1423,10 @@ class Account( when (it) { is NPub -> null is NProfile -> null - is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> LocalCache.getOrCreateNote(it.hex) - is NEvent -> LocalCache.getOrCreateNote(it.hex) - is NEmbed -> LocalCache.getOrCreateNote(it.event.id) - is NAddress -> LocalCache.checkGetOrCreateAddressableNote(it.aTag()) + is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> cache.getOrCreateNote(it.hex) + is NEvent -> cache.getOrCreateNote(it.hex) + is NEmbed -> cache.getOrCreateNote(it.event.id) + is NAddress -> cache.checkGetOrCreateAddressableNote(it.aTag()) is NSec -> null is NRelay -> null else -> null @@ -2460,7 +1445,7 @@ class Account( zapRaiserAmount: Long? = null, imetas: List? = null, draftTag: String? = null, - relayList: List, + relayList: Set, ) { if (!isWriteable()) return @@ -2497,7 +1482,7 @@ class Account( zapRaiserAmount: Long? = null, imetas: List? = null, draftTag: String? = null, - relayList: List, + relayList: Set, ) { if (!isWriteable()) return @@ -2526,7 +1511,6 @@ class Account( value: BigDecimal, bounty: Note, draftTag: String?, - relayList: List, ) { if (!isWriteable()) return @@ -2540,7 +1524,9 @@ class Account( eventAuthor.toPTag(), ) - signAndSend(draftTag, template, relayList, setOf(bounty)) + val relays = bounty.relays + outboxRelays.flow.value + + signAndSendWithList(draftTag, template, relays, setOf(bounty)) } fun sendEdit( @@ -2548,7 +1534,7 @@ class Account( originalNote: Note, notify: HexKey?, summary: String? = null, - relayList: List, + relayList: List, ) { if (!isWriteable()) return @@ -2561,37 +1547,11 @@ class Account( summary = summary, signer = signer, ) { - LocalCache.justConsumeMyOwnEvent(it) - Amethyst.instance.client.send(it, relayList = relayList) + cache.justConsumeMyOwnEvent(it) + client.send(it, relayList = relayList.toSet()) } } - fun sendPrivateMessage( - message: String, - toUser: User, - replyingTo: Note? = null, - zapReceiver: List? = null, - contentWarningReason: String? = null, - zapRaiserAmount: Long? = null, - geohash: String? = null, - imetas: List? = null, - emojis: List? = null, - draftTag: String?, - ) { - sendPrivateMessage( - message, - toUser.toPTag(), - replyingTo, - zapReceiver, - contentWarningReason, - zapRaiserAmount, - geohash, - imetas, - emojis, - draftTag, - ) - } - fun sendPrivateMessage( message: String, toUser: PTag, @@ -2621,7 +1581,9 @@ class Account( contentWarningReason?.let { contentWarning(contentWarningReason) } } - signAndSend(draftTag, template) + val destinationRelays = cache.getOrCreateUser(toUser.pubKey).dmInboxRelays() + + signAndSendNIP04Message(draftTag, template, outboxRelays.flow.value + destinationRelays) } } @@ -2656,43 +1618,20 @@ class Account( } } - fun getPrivateOutBoxRelayList(): List = - normalizedPrivateOutBoxRelaySet.value.map { - RelaySetupInfoToConnect( - it, - shouldUseTorForClean(it), - true, - true, - emptySet(), - ) - } - fun sendDraftEvent(draftEvent: DraftEvent) { sendToPrivateOutboxAndLocal(draftEvent) } fun sendToPrivateOutboxAndLocal(event: Event) { - val relayList = normalizedPrivateOutBoxRelaySet.value + settings.localRelayServers + val relayList = privateStorageRelayList.flow.value + localRelayList.flow.value if (relayList.isNotEmpty()) { - Amethyst.instance.client.sendPrivately(event, convertRelayList(relayList.toList())) + client.send(event, relayList.toSet()) } else { - Amethyst.instance.client.send(event) + client.send(event, outboxRelays.flow.value) } - LocalCache.justConsumeMyOwnEvent(event) + cache.justConsumeMyOwnEvent(event) } - fun convertRelayList(broadcast: List): List = - broadcast.map { - val normalizedUrl = RelayUrlFormatter.normalize(it) - RelaySetupInfoToConnect( - normalizedUrl, - shouldUseTorForClean(normalizedUrl), - true, - true, - setOf(FeedType.GLOBAL), - ) - } - fun broadcastPrivately(signedEvents: NIP17Factory.Result) { val mine = signedEvents.wraps.filter { (it.recipientPubKey() == signer.pubKey) } @@ -2700,140 +1639,72 @@ class Account( giftWrap.unwrap(signer) { gift -> if (gift is SealedRumorEvent) { gift.unseal(signer) { rumor -> - LocalCache.justConsumeMyOwnEvent(rumor) + cache.justConsumeMyOwnEvent(rumor) } } - LocalCache.justConsumeMyOwnEvent(gift) + cache.justConsumeMyOwnEvent(gift) } - LocalCache.justConsumeMyOwnEvent(giftWrap) + cache.justConsumeMyOwnEvent(giftWrap) } val id = mine.firstOrNull()?.id - val mineNote = if (id == null) null else LocalCache.getNoteIfExists(id) + val mineNote = if (id == null) null else cache.getNoteIfExists(id) signedEvents.wraps.forEach { wrap -> // Creates an alias if (mineNote != null && wrap.recipientPubKey() != signer.pubKey) { - LocalCache.getOrAddAliasNote(wrap.id, mineNote) + cache.getOrAddAliasNote(wrap.id, mineNote) } val receiver = wrap.recipientPubKey() if (receiver != null) { val relayList = ( - LocalCache + cache .getAddressableNoteIfExists(ChatMessageRelayListEvent.createAddressTag(receiver)) ?.event as? ChatMessageRelayListEvent - )?.relays()?.ifEmpty { null }?.map { - val normalizedUrl = RelayUrlFormatter.normalize(it) - RelaySetupInfoToConnect( - normalizedUrl, - shouldUseTorForClean(normalizedUrl), - false, - true, - feedTypes = setOf(FeedType.PRIVATE_DMS), - ) - } + )?.relays()?.ifEmpty { null }?.toSet() if (relayList != null) { - Amethyst.instance.client.sendPrivately(signedEvent = wrap, relayList = relayList) + client.send(signedEvent = wrap, relayList = relayList) } else { - Amethyst.instance.client.send(wrap) + val taggedUserInboxRelays = + wrap.taggedUserIds().flatMapTo(mutableSetOf()) { pubkey -> + if (pubkey == userProfile().pubkeyHex) { + notificationRelays.flow.value + } else { + LocalCache.getUserIfExists(pubkey)?.inboxRelays()?.ifEmpty { null }?.toSet() + ?: cache.relayHints.hintsForKey(pubkey).toSet() + } + } + + client.send(wrap, taggedUserInboxRelays) } } else { - Amethyst.instance.client.send(wrap) + client.send(wrap, outboxRelays.flow.value) } } } + fun createStatus(newStatus: String) = UserStatusAction.create(newStatus, signer, ::sendMyPublicAndPrivateOutbox) + fun updateStatus( oldStatus: AddressableNote, newStatus: String, - ) { - if (!isWriteable()) return - val oldEvent = oldStatus.event as? StatusEvent ?: return + ) = UserStatusAction.update(oldStatus, newStatus, signer, ::sendMyPublicAndPrivateOutbox) - StatusEvent.update(oldEvent, newStatus, signer) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } + fun deleteStatus(oldStatus: AddressableNote) = UserStatusAction.delete(oldStatus, signer, ::sendMyPublicAndPrivateOutbox) - fun createStatus(newStatus: String) { - if (!isWriteable()) return + fun removeEmojiPack(emojiPack: Note) = emoji.removeEmojiPack(emojiPack, ::sendMyPublicAndPrivateOutbox) - StatusEvent.create(newStatus, "general", expiration = null, signer) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - - fun deleteStatus(oldStatus: AddressableNote) { - if (!isWriteable()) return - val oldEvent = oldStatus.event as? StatusEvent ?: return - - StatusEvent.clear(oldEvent, signer) { event -> - Amethyst.instance.client.send(event) - LocalCache.justConsumeMyOwnEvent(event) - - signer.sign( - DeletionEvent.buildForVersionOnly(listOf(event)), - ) { event2 -> - Amethyst.instance.client.send(event2) - LocalCache.justConsumeMyOwnEvent(event2) - } - } - } - - fun removeEmojiPack( - usersEmojiList: Note, - emojiPack: Note, - ) { - if (!isWriteable()) return - - val noteEvent = usersEmojiList.event - if (noteEvent !is EmojiPackSelectionEvent) return - val emojiPackEvent = emojiPack.event - if (emojiPackEvent !is EmojiPackEvent) return - - signer.sign(EmojiPackSelectionEvent.remove(noteEvent, emojiPackEvent)) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - - fun addEmojiPack( - usersEmojiList: Note, - emojiPack: Note, - ) { - if (!isWriteable()) return - val emojiPackEvent = emojiPack.event - if (emojiPackEvent !is EmojiPackEvent) return - - val eventHint = emojiPack.toEventHint() ?: return - - if (usersEmojiList.event == null) { - signer.sign(EmojiPackSelectionEvent.build(listOf(eventHint))) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - val noteEvent = usersEmojiList.event - if (noteEvent !is EmojiPackSelectionEvent) return - - signer.sign(EmojiPackSelectionEvent.add(noteEvent, eventHint)) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } + fun addEmojiPack(emojiPack: Note) = emoji.addEmojiPack(emojiPack, ::sendMyPublicAndPrivateOutbox) fun addToGallery( idHex: HexKey, url: String, - relay: String?, + relay: NormalizedRelayUrl?, blurhash: String?, dim: DimensionTag?, hash: String?, @@ -2850,8 +1721,8 @@ class Account( blurhash?.let { blurhash(it) } }, ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) + client.send(it, outboxRelays.flow.value) + cache.justConsumeMyOwnEvent(it) } } @@ -2873,8 +1744,8 @@ class Account( isPrivate, signer, ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) + client.send(it, outboxRelays.flow.value) + cache.justConsumeMyOwnEvent(it) } } else { BookmarkListEvent.addEvent( @@ -2883,8 +1754,8 @@ class Account( isPrivate, signer, ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) + client.send(it, outboxRelays.flow.value) + cache.justConsumeMyOwnEvent(it) } } } @@ -2904,8 +1775,8 @@ class Account( isPrivate, signer, ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) + client.send(it, outboxRelays.flow.value) + cache.justConsumeMyOwnEvent(it) } } else { BookmarkListEvent.removeEvent( @@ -2914,23 +1785,23 @@ class Account( isPrivate, signer, ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) + client.send(it, outboxRelays.flow.value) + cache.justConsumeMyOwnEvent(it) } } } fun sendAuthEvent( - relay: Relay, + relay: IRelayClient, challenge: String, ) { createAuthEvent(relay.url, challenge) { - Amethyst.instance.client.sendIfExists(it, relay) + client.sendIfExists(it, relay.url) } } fun createAuthEvent( - relayUrl: String, + relayUrl: NormalizedRelayUrl, challenge: String, onReady: (RelayAuthEvent) -> Unit, ) { @@ -2940,7 +1811,7 @@ class Account( } fun createAuthEvent( - relayUrls: List, + relayUrls: List, challenge: String, onReady: (RelayAuthEvent) -> Unit, ) { @@ -2976,165 +1847,53 @@ class Account( fun isInPublicBookmarks(note: Note): Boolean { if (!isWriteable()) return false - if (note is AddressableNote) { - return userProfile().latestBookmarkList?.isTaggedAddressableNote(note.idHex) == true + return if (note is AddressableNote) { + userProfile().latestBookmarkList?.isTaggedAddressableNote(note.idHex) == true } else { - return userProfile().latestBookmarkList?.isTaggedEvent(note.idHex) == true + userProfile().latestBookmarkList?.isTaggedEvent(note.idHex) == true } } - fun getAppSpecificDataNote() = LocalCache.getOrCreateAddressableNote(AppSpecificDataEvent.createAddress(userProfile().pubkeyHex, APP_SPECIFIC_DATA_D_TAG)) - - fun getAppSpecificDataFlow(): StateFlow = getAppSpecificDataNote().flow().metadata.stateFlow - - fun getBlockListNote() = LocalCache.getOrCreateAddressableNote(PeopleListEvent.createBlockAddress(userProfile().pubkeyHex)) - - fun getMuteListNote() = LocalCache.getOrCreateAddressableNote(MuteListEvent.createAddress(userProfile().pubkeyHex)) - - fun getMuteListFlow(): StateFlow = getMuteListNote().flow().metadata.stateFlow - - fun getBlockList(): PeopleListEvent? = getBlockListNote().event as? PeopleListEvent - - fun getMuteList(): MuteListEvent? = getMuteListNote().event as? MuteListEvent - fun hideWord(word: String) { - val muteList = getMuteList() + if (!isWriteable()) return - if (muteList != null) { - MuteListEvent.addWord( - earlierVersion = muteList, - word = word, - isPrivate = true, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - MuteListEvent.createListWithWord( - word = word, - isPrivate = true, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } + muteList.hideWord(word, ::sendMyPublicAndPrivateOutbox) } fun showWord(word: String) { - val blockList = getBlockList() + if (!isWriteable()) return - if (blockList != null) { - PeopleListEvent.removeWord( - earlierVersion = blockList, - word = word, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - - val muteList = getMuteList() - - if (muteList != null) { - MuteListEvent.removeWord( - earlierVersion = muteList, - word = word, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } + blockPeopleList.showWord(word, ::sendMyPublicAndPrivateOutbox) + muteList.showWord(word, ::sendMyPublicAndPrivateOutbox) } - fun hideUser(pubkeyHex: String) { - val muteList = getMuteList() + fun hideUser(pubkeyHex: HexKey) { + if (!isWriteable()) return - if (muteList != null) { - MuteListEvent.addUser( - earlierVersion = muteList, - pubKeyHex = pubkeyHex, - isPrivate = true, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - MuteListEvent.createListWithUser( - pubKeyHex = pubkeyHex, - isPrivate = true, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } + muteList.hideUser(pubkeyHex, ::sendMyPublicAndPrivateOutbox) } - fun showUser(pubkeyHex: String) { - val blockList = getBlockList() + fun showUser(pubkeyHex: HexKey) { + if (!isWriteable()) return - if (blockList != null) { - PeopleListEvent.removeUser( - earlierVersion = blockList, - pubKeyHex = pubkeyHex, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - - val muteList = getMuteList() - - if (muteList != null) { - MuteListEvent.removeUser( - earlierVersion = muteList, - pubKeyHex = pubkeyHex, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - - transientHiddenUsers.update { - it - pubkeyHex - } + blockPeopleList.showUser(pubkeyHex, ::sendMyPublicAndPrivateOutbox) + muteList.showUser(pubkeyHex, ::sendMyPublicAndPrivateOutbox) + hiddenUsers.showUser(pubkeyHex) } fun requestDVMContentDiscovery( - dvmPublicKey: String, + dvmPublicKey: User, onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit, ) { - NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey, signer.pubKey, getReceivingRelays(), signer) { + val relays = nip65RelayList.inboxFlow.value.toSet() + NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey.pubkeyHex, signer.pubKey, relays, signer) { val relayList = - ( - LocalCache - .getAddressableNoteIfExists( - AdvertisedRelayListEvent.createAddressTag(dvmPublicKey), - )?.event as? AdvertisedRelayListEvent - )?.readRelays()?.ifEmpty { null }?.map { - val normalizedUrl = RelayUrlFormatter.normalize(it) - RelaySetupInfoToConnect( - normalizedUrl, - shouldUseTorForClean(normalizedUrl), - true, - true, - setOf(FeedType.GLOBAL), - ) - } + dvmPublicKey.inboxRelays().ifEmpty { + LocalCache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex) + }.toSet() - if (relayList != null) { - Amethyst.instance.client.sendPrivately(it, relayList) - } else { - Amethyst.instance.client.send(it) - } - LocalCache.justConsumeMyOwnEvent(it) + client.send(it, relayList) + cache.justConsumeMyOwnEvent(it) onReady(it) } } @@ -3211,59 +1970,13 @@ class Account( } } - // Takes a User's relay list and adds the types of feeds they are active for. - fun kind3Relays(): Array? { - val usersRelayList = - (userProfile().latestContactList ?: settings.backupContactList) - ?.relays() - ?.map { - val url = RelayUrlFormatter.normalize(it.key) - - val localFeedTypes = - settings.localRelays - .firstOrNull { localRelay -> RelayUrlFormatter.normalize(localRelay.url) == url } - ?.feedTypes - ?.minus(setOf(FeedType.SEARCH, FeedType.WALLET_CONNECT)) - ?: Constants.defaultRelays - .filter { defaultRelay -> RelayUrlFormatter.normalize(defaultRelay.url) == url } - .firstOrNull() - ?.feedTypes - ?: Constants.activeTypesGlobalChats - - RelaySetupInfo(url, it.value.read, it.value.write, localFeedTypes) - }?.ifEmpty { null } ?: return null - - return usersRelayList.toTypedArray() - } - - fun convertLocalRelays(): Array = - settings.localRelays - .map { - RelaySetupInfo( - RelayUrlFormatter.normalize(it.url), - it.read, - it.write, - it.feedTypes.minus(setOf(FeedType.SEARCH, FeedType.WALLET_CONNECT)), - ) - }.toTypedArray() - - fun activeGlobalRelays(): Array = - connectToRelays.value - .filter { it.feedTypes.contains(FeedType.GLOBAL) } - .map { it.url } - .toTypedArray() - - fun activeWriteRelays(): List = connectToRelays.value.filter { it.write } - fun isAllHidden(users: Set): Boolean = users.all { isHidden(it) } fun isHidden(user: User) = isHidden(user.pubkeyHex) - fun isHidden(userHex: String): Boolean = - flowHiddenUsers.value.hiddenUsers.contains(userHex) || - flowHiddenUsers.value.spammers.contains(userHex) + fun isHidden(userHex: String): Boolean = hiddenUsers.flow.value.isUserHidden(userHex) - fun followingKeySet(): Set = liveKind3Follows.value.authors + fun followingKeySet(): Set = kind3FollowList.flow.value.authors fun isAcceptable(user: User): Boolean { if (userProfile().pubkeyHex == user.pubkeyHex) { @@ -3321,229 +2034,46 @@ class Account( } return ( - note.reportsBy(liveKind3Follows.value.authorsPlusMe) + - (note.author?.reportsBy(liveKind3Follows.value.authorsPlusMe) ?: emptyList()) + + note.reportsBy(kind3FollowList.flow.value.authorsPlusMe) + + (note.author?.reportsBy(kind3FollowList.flow.value.authorsPlusMe) ?: emptyList()) + innerReports ).toSet() } - fun saveKind3RelayList(value: List) { - settings.updateLocalRelays(value.toSet()) - sendKind3RelayList( - value.associate { it.url to ReadWrite(it.read, it.write) }, - ) - } - - fun getDMRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(ChatMessageRelayListEvent.createAddress(signer.pubKey)) - - fun getDMRelayListFlow(): StateFlow = getDMRelayListNote().flow().metadata.stateFlow - - fun getDMRelayList(): ChatMessageRelayListEvent? = getDMRelayListNote().event as? ChatMessageRelayListEvent - - fun saveDMRelayList(dmRelays: List) { + fun saveDMRelayList(dmRelays: List) { if (!isWriteable()) return - - val relayListForDMs = getDMRelayList() - if (relayListForDMs != null && relayListForDMs.tags.isNotEmpty()) { - ChatMessageRelayListEvent.updateRelayList( - earlierVersion = relayListForDMs, - relays = dmRelays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - ChatMessageRelayListEvent.createFromScratch( - relays = dmRelays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } + dmRelayList.saveRelayList(dmRelays, ::sendMyPublicAndPrivateOutbox) } - fun getPrivateOutboxRelayListNote(): AddressableNote = - LocalCache.getOrCreateAddressableNote( - PrivateOutboxRelayListEvent.createAddress(signer.pubKey), - ) - - fun getPrivateOutboxRelayListFlow(): StateFlow = getPrivateOutboxRelayListNote().flow().metadata.stateFlow - - fun getPrivateOutboxRelayList(): PrivateOutboxRelayListEvent? = getPrivateOutboxRelayListNote().event as? PrivateOutboxRelayListEvent - - fun savePrivateOutboxRelayList(relays: List) { + fun savePrivateOutboxRelayList(relays: List) { if (!isWriteable()) return - - val relayListForPrivateOutbox = getPrivateOutboxRelayList() - - if (relayListForPrivateOutbox != null && !relayListForPrivateOutbox.cachedPrivateTags().isNullOrEmpty()) { - PrivateOutboxRelayListEvent.updateRelayList( - earlierVersion = relayListForPrivateOutbox, - relays = relays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - PrivateOutboxRelayListEvent.createFromScratch( - relays = relays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } + privateStorageRelayList.saveRelayList(relays, ::sendMyPublicAndPrivateOutbox) } - fun getSearchRelayListNote(): AddressableNote = - LocalCache.getOrCreateAddressableNote( - SearchRelayListEvent.createAddress(signer.pubKey), - ) - - fun getSearchRelayListFlow(): StateFlow = getSearchRelayListNote().flow().metadata.stateFlow - - fun getSearchRelayList(): SearchRelayListEvent? = getSearchRelayListNote().event as? SearchRelayListEvent - - fun saveSearchRelayList(searchRelays: List) { + fun saveSearchRelayList(searchRelays: List) { if (!isWriteable()) return - - val relayListForSearch = getSearchRelayList() - - if (relayListForSearch != null && relayListForSearch.tags.isNotEmpty()) { - SearchRelayListEvent.updateRelayList( - earlierVersion = relayListForSearch, - relays = searchRelays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - SearchRelayListEvent.createFromScratch( - relays = searchRelays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } + searchRelayList.saveRelayList(searchRelays, ::sendMyPublicAndPrivateOutbox) } - fun getNIP65RelayListNote(pubkey: HexKey = signer.pubKey): AddressableNote = - LocalCache.getOrCreateAddressableNote( - AdvertisedRelayListEvent.createAddress(pubkey), - ) - - fun getNIP65RelayListFlow(pubkey: HexKey = signer.pubKey): StateFlow = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow - - fun getNIP65RelayList(pubkey: HexKey = signer.pubKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent - - fun sendNip65RelayList(relays: List) { + fun sendNip65RelayList(relays: List) { if (!isWriteable()) return - - val nip65RelayList = getNIP65RelayList() - - if (nip65RelayList != null) { - AdvertisedRelayListEvent.updateRelayList( - earlierVersion = nip65RelayList, - relays = relays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - AdvertisedRelayListEvent.createFromScratch( - relays = relays, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } - } - - fun getFileServersList(): FileServersEvent? = getFileServersNote().event as? FileServersEvent - - fun getFileServersListFlow(): StateFlow = getFileServersNote().flow().metadata.stateFlow - - fun getFileServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(FileServersEvent.createAddress(userProfile().pubkeyHex)) - - fun getBlossomServersList(): BlossomServersEvent? = getBlossomServersNote().event as? BlossomServersEvent - - fun getBlossomServersListFlow(): StateFlow = getBlossomServersNote().flow().metadata.stateFlow - - fun getBlossomServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(BlossomServersEvent.createAddress(userProfile().pubkeyHex)) - - fun host(url: String): String = - try { - URIReference.parse(url).host.value - } catch (e: Exception) { - url - } - - fun mergeServerList( - nip96: FileServersEvent?, - blossom: BlossomServersEvent?, - ): List { - val nip96servers = nip96?.servers()?.map { ServerName(host(it), it, ServerType.NIP96) } ?: emptyList() - val blossomServers = blossom?.servers()?.map { ServerName(host(it), it, ServerType.Blossom) } ?: emptyList() - - val result = (nip96servers + blossomServers).ifEmpty { DEFAULT_MEDIA_SERVERS } - - return result + ServerName("NIP95", "", ServerType.NIP95) + nip65RelayList.saveRelayList(relays, ::sendMyPublicAndPrivateOutbox) } fun sendFileServersList(servers: List) { if (!isWriteable()) return - - val serverList = getFileServersList() - - val template = - if (serverList != null && serverList.tags.isNotEmpty()) { - FileServersEvent.replaceServers(serverList, servers) - } else { - FileServersEvent.build(servers) - } - - signer.sign(template) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } + fileStorageServers.saveFileServersList(servers, ::sendMyPublicAndPrivateOutbox) } fun sendBlossomServersList(servers: List) { if (!isWriteable()) return - - val serverList = getBlossomServersList() - - if (serverList != null && serverList.tags.isNotEmpty()) { - BlossomServersEvent.updateRelayList( - earlierVersion = serverList, - relays = servers, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } else { - BlossomServersEvent.createFromScratch( - relays = servers, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.justConsumeMyOwnEvent(it) - } - } + blossomServers.saveBlossomServersList(servers, ::sendMyPublicAndPrivateOutbox) } fun getAllPeopleLists(): List = getAllPeopleLists(signer.pubKey) fun getAllPeopleLists(pubkey: HexKey): List = - LocalCache.addressables + cache.addressables .filter { _, addressableNote -> val noteEvent = addressableNote.event @@ -3596,9 +2126,9 @@ class Account( normalizedUrl: String, final: Boolean, ): Boolean = - if (isLocalHost(normalizedUrl)) { + if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) { false - } else if (isOnionUrl(normalizedUrl)) { + } else if (RelayUrlNormalizer.isOnion(normalizedUrl)) { true } else { final @@ -3632,46 +2162,26 @@ class Account( TorType.EXTERNAL -> settings.torSettings.trustedRelaysViaTor.value } - fun shouldUseTorForDirty(dirtyUrl: String) = shouldUseTorForClean(RelayUrlFormatter.normalize(dirtyUrl)) - - fun shouldUseTorForClean(normalizedUrl: String) = - when (settings.torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> shouldUseTor(normalizedUrl) - TorType.EXTERNAL -> shouldUseTor(normalizedUrl) - } + fun shouldUseTorForClean(relay: NormalizedRelayUrl) = torRelayState.flow.value.useTor(relay) private fun checkLocalHostOnionAndThen( - normalizedUrl: String, + url: String, final: Boolean, - ): Boolean = checkLocalHostOnionAndThen(normalizedUrl, settings.torSettings.onionRelaysViaTor.value, final) + ): Boolean = checkLocalHostOnionAndThen(url, settings.torSettings.onionRelaysViaTor.value, final) private fun checkLocalHostOnionAndThen( normalizedUrl: String, isOnionRelaysActive: Boolean, final: Boolean, ): Boolean = - if (isLocalHost(normalizedUrl)) { + if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) { false - } else if (isOnionUrl(normalizedUrl)) { + } else if (RelayUrlNormalizer.isOnion(normalizedUrl)) { isOnionRelaysActive } else { final } - private fun shouldUseTor(normalizedUrl: String): Boolean = - if (isLocalHost(normalizedUrl)) { - false - } else if (isOnionUrl(normalizedUrl)) { - settings.torSettings.onionRelaysViaTor.value - } else if (isDMRelay(normalizedUrl)) { - settings.torSettings.dmRelaysViaTor.value - } else if (isTrustedRelay(normalizedUrl)) { - settings.torSettings.trustedRelaysViaTor.value - } else { - settings.torSettings.newRelaysViaTor.value - } - fun shouldUseTorForMoneyOperations(url: String) = when (settings.torSettings.torType.value) { TorType.OFF -> false @@ -3693,14 +2203,6 @@ class Account( TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip96UploadsViaTor.value) } - fun isLocalHost(url: String) = url.contains("//127.0.0.1") || url.contains("//localhost") - - fun isOnionUrl(url: String) = url.contains(".onion") - - fun isDMRelay(url: String) = url in normalizedDmRelaySet.value - - fun isTrustedRelay(url: String): Boolean = connectToRelays.value.any { it.url == url } || url == settings.zapPaymentRequest?.relayUri - fun otsResolver(): OtsResolver = OtsResolverBuilder().build( Amethyst.instance.okHttpClients, @@ -3710,215 +2212,13 @@ class Account( init { Log.d("AccountRegisterObservers", "Init") - settings.backupContactList?.let { - Log.d("AccountRegisterObservers", "Loading saved contacts ${it.toJson()}") - - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } - } - - settings.backupUserMetadata?.let { - Log.d("AccountRegisterObservers", "Loading saved user metadata ${it.toJson()}") - - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } - } - - settings.backupDMRelayList?.let { - Log.d("AccountRegisterObservers", "Loading saved DM Relay List ${it.toJson()}") - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } - } - - settings.backupNIP65RelayList?.let { - Log.d("AccountRegisterObservers", "Loading saved nip65 relay list ${it.toJson()}") - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } - } - - settings.backupSearchRelayList?.let { - Log.d("AccountRegisterObservers", "Loading saved search relay list ${it.toJson()}") - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } - } - - settings.backupPrivateHomeRelayList?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved private home relay list ${event.toJson()}") - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { - event.privateTags(signer) { - LocalCache.justConsumeMyOwnEvent(event) - } - } - } - - settings.backupAppSpecificData?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved app specific data ${event.toJson()}") - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { - LocalCache.justConsumeMyOwnEvent(event) - signer.decrypt(event.content, event.pubKey) { decrypted -> - try { - val syncedSettings = EventMapper.mapper.readValue(decrypted) - settings.syncedSettings.updateFrom(syncedSettings) - } catch (e: Throwable) { - if (e is CancellationException) throw e - Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value $decrypted", e) - e.printStackTrace() - AccountSyncedSettingsInternal() - } - } - } - } - - settings.backupMuteList?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved mute list ${event.toJson()}") - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { - event.privateTags(signer) { - LocalCache.justConsumeMyOwnEvent(event) - } - } - } - - settings.backupEphemeralChatList?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved ephemeral chat list ${event.toJson()}") - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { - event.privateTags(signer) { - LocalCache.justConsumeMyOwnEvent(event) - } - } - } - - settings.backupChannelList?.let { event -> - Log.d("AccountRegisterObservers", "Loading saved channel list ${event.toJson()}") - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { - event.privateTags(signer) { - LocalCache.justConsumeMyOwnEvent(event) - } - } - } - - // saves contact list for the next time. - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "Kind 0 Collector Start") - userProfile().flow().metadata.stateFlow.collect { - Log.d("AccountRegisterObservers", "Updating Kind 0 ${userProfile().toBestDisplayName()}") - settings.updateUserMetadata(userProfile().latestMetadata) - } - } - - // saves contact list for the next time. - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "Kind 3 Collector Start") - userProfile().flow().follows.stateFlow.collect { - Log.d("AccountRegisterObservers", "Updating Kind 3 ${userProfile().toBestDisplayName()}") - settings.updateContactListTo(userProfile().latestContactList) - } - } scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "NIP-17 Relay List Collector Start") - getDMRelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating DM Relay List for ${userProfile().toBestDisplayName()}") - (it.note.event as? ChatMessageRelayListEvent)?.let { - settings.updateDMRelayList(it) - } - } - } - - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "NIP-65 Relay List Collector Start") - getNIP65RelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating NIP-65 List for ${userProfile().toBestDisplayName()}") - (it.note.event as? AdvertisedRelayListEvent)?.let { - settings.updateNIP65RelayList(it) - } - } - } - - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "Search Relay List Collector Start") - getSearchRelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating Search Relay List for ${userProfile().toBestDisplayName()}") - (it.note.event as? SearchRelayListEvent)?.let { - settings.updateSearchRelayList(it) - } - } - } - - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "Private Home Relay List Collector Start") - getPrivateOutboxRelayListFlow().collect { - Log.d("AccountRegisterObservers", "Updating Private Home Relay List for ${userProfile().toBestDisplayName()}") - (it.note.event as? PrivateOutboxRelayListEvent)?.let { - settings.updatePrivateHomeRelayList(it) - } - } - } - - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "Mute List Collector Start") - getMuteListFlow().collect { - Log.d("AccountRegisterObservers", "Updating Mute List for ${userProfile().toBestDisplayName()}") - (it.note.event as? MuteListEvent)?.let { - settings.updateMuteList(it) - } - } - } - - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "Channel List Collector Start") - publicChatList.getChannelListFlow().collect { - Log.d("AccountRegisterObservers", "Channel List for ${userProfile().toBestDisplayName()}") - (it.note.event as? ChannelListEvent)?.let { - settings.updateChannelListTo(it) - } - } - } - - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "EphemeralChatList Collector Start") - ephemeralChatList.getEphemeralChatListFlow().collect { - Log.d("AccountRegisterObservers", "EphemeralChatList List for ${userProfile().toBestDisplayName()}") - (it.note.event as? EphemeralChatListEvent)?.let { - settings.updateEphemeralChatListTo(it) - } - } - } - - scope.launch(Dispatchers.Default) { - Log.d("AccountRegisterObservers", "AppSpecificData Collector Start") - getAppSpecificDataFlow().collect { - Log.d("AccountRegisterObservers", "Updating AppSpecificData for ${userProfile().toBestDisplayName()}") - (it.note.event as? AppSpecificDataEvent)?.let { - signer.decrypt(it.content, it.pubKey) { decrypted -> - val syncedSettings = - try { - EventMapper.mapper.readValue(decrypted) - } catch (e: Throwable) { - if (e is CancellationException) throw e - Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value $decrypted", e) - e.printStackTrace() - AccountSyncedSettingsInternal() - } - - settings.updateAppSpecificData(it, syncedSettings) - } - } - } - } - - scope.launch(Dispatchers.Default) { - LocalCache.antiSpam.flowSpam.collect { + cache.antiSpam.flowSpam.collect { it.cache.spamMessages.snapshot().values.forEach { spammer -> - if (spammer.pubkeyHex !in transientHiddenUsers.value && spammer.duplicatedMessages.size >= 5) { + if (!hiddenUsers.isHidden(spammer.pubkeyHex) && spammer.duplicatedMessages.size >= 5) { if (spammer.pubkeyHex != userProfile().pubkeyHex && spammer.pubkeyHex !in followingKeySet()) { - transientHiddenUsers.update { - it + spammer.pubkeyHex - } + hiddenUsers.hideUser(spammer.pubkeyHex) } } } @@ -3929,11 +2229,11 @@ class Account( delay(1000 * 60 * 1) // waits 5 minutes before migrating the list. val contactList = userProfile().latestContactList - val oldChannels = contactList?.taggedEventIds()?.toSet()?.mapNotNull { LocalCache.getChannelIfExists(it) as? PublicChatChannel } + val oldChannels = contactList?.taggedEventIds()?.toSet()?.mapNotNull { cache.getChannelIfExists(it) as? PublicChatChannel } if (oldChannels != null && oldChannels.isNotEmpty()) { Log.d("DB UPGRADE", "Migrating List with ${oldChannels.size} old channels ") - val existingChannels = publicChatList.livePublicChatEventIdSet.value + val existingChannels = publicChatList.flowSet.value val needsToUpgrade = oldChannels.filter { it.idHex !in existingChannels } @@ -3941,10 +2241,55 @@ class Account( if (needsToUpgrade.isNotEmpty()) { Log.d("DB UPGRADE", "Migrating List") - publicChatList.follow(oldChannels) { - sendToPrivateOutboxAndLocal(it) - LocalCache.justConsumeMyOwnEvent(it) - } + publicChatList.follow(oldChannels, ::sendMyPublicAndPrivateOutbox) + } + } + + val oldCommunities = contactList?.taggedAddresses()?.toSet()?.map { cache.getOrCreateAddressableNote(it) } + + if (oldCommunities != null && oldCommunities.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List with ${oldCommunities.size} old communities ") + val existingCommunities = communityList.flowSet.value + + val needsToUpgrade = oldCommunities.filter { it.idHex !in existingCommunities } + + Log.d("DB UPGRADE", "Migrating List with ${needsToUpgrade.size} needsToUpgrade ") + + if (needsToUpgrade.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List") + communityList.follow(oldCommunities, ::sendMyPublicAndPrivateOutbox) + } + } + + val oldHashtags = contactList?.hashtags()?.toSet() + + if (oldHashtags != null && oldHashtags.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List with ${oldHashtags.size} old communities ") + val existingHashtags = hashtagList.flow.value + + val needsToUpgrade = oldHashtags.filter { it !in existingHashtags } + + Log.d("DB UPGRADE", "Migrating List with ${needsToUpgrade.size} needsToUpgrade ") + + if (needsToUpgrade.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List") + hashtagList.follow(oldHashtags.toList(), ::sendMyPublicAndPrivateOutbox) + } + } + + val oldGeohashes = contactList?.geohashes()?.toSet() + + if (oldGeohashes != null && oldGeohashes.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List with ${oldGeohashes.size} old communities ") + val existingGeohashes = geohashList.flow.value + + val needsToUpgrade = oldGeohashes.filter { it !in existingGeohashes } + + Log.d("DB UPGRADE", "Migrating List with ${needsToUpgrade.size} needsToUpgrade ") + + if (needsToUpgrade.isNotEmpty()) { + Log.d("DB UPGRADE", "Migrating List") + geohashList.follow(oldGeohashes.toList(), ::sendMyPublicAndPrivateOutbox) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 372df358d..2933b790f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -27,8 +27,6 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow -import com.vitorpamplona.ammolite.relays.Constants -import com.vitorpamplona.ammolite.relays.RelaySetupInfo import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -43,11 +41,15 @@ import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent import com.vitorpamplona.quartz.nip55AndroidSigner.ExternalSignerLauncher import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow @@ -67,38 +69,29 @@ val DefaultChannelSet = val DefaultChannels = listOf( // Anigma's Nostr - EventIdHint("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", "wss://nos.lol"), + EventIdHint("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", Constants.nos), // Amethyst's Group - EventIdHint("42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", "wss://nos.lol"), + EventIdHint("42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", Constants.nos), ) +val DefaultNIP65RelaySet = setOf(Constants.mom, Constants.nos, Constants.bitcoiner) + val DefaultNIP65List = listOf( - AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.mom/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH), - AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nos.lol/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH), - AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.bitcoiner.social/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH), + AdvertisedRelayInfo(Constants.mom, AdvertisedRelayType.BOTH), + AdvertisedRelayInfo(Constants.nos, AdvertisedRelayType.BOTH), + AdvertisedRelayInfo(Constants.bitcoiner, AdvertisedRelayType.BOTH), ) -val DefaultDMRelayList = - listOf( - RelayUrlFormatter.normalize("wss://auth.nostr1.com"), - RelayUrlFormatter.normalize("wss://relay.0xchat.com"), - RelayUrlFormatter.normalize("wss://nos.lol"), - ) +val DefaultDMRelayList = listOf(Constants.auth, Constants.oxchat, Constants.nos) -val DefaultSearchRelayList = - listOf( - RelayUrlFormatter.normalize("wss://relay.nostr.band"), - RelayUrlFormatter.normalize("wss://nostr.wine"), - RelayUrlFormatter.normalize("wss://relay.noswhere.com"), - RelayUrlFormatter.normalize("wss://search.nos.today"), - ) +val DefaultSearchRelayList = setOf(Constants.band, Constants.wine, Constants.where, Constants.nostoday) // This has spaces to avoid mixing with a potential NIP-51 list with the same name. val GLOBAL_FOLLOWS = " Global " // This has spaces to avoid mixing with a potential NIP-51 list with the same name. -val KIND3_FOLLOWS = " All Follows " +val ALL_FOLLOWS = " All Follows " // This has spaces to avoid mixing with a potential NIP-51 list with the same name. val AROUND_ME = " Around Me " @@ -108,14 +101,13 @@ class AccountSettings( val keyPair: KeyPair, val transientAccount: Boolean = false, var externalSignerPackageName: String? = null, - var localRelays: Set = Constants.defaultRelays.toSet(), - var localRelayServers: Set = setOf(), + var localRelayServers: MutableStateFlow> = MutableStateFlow(setOf()), var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0], - val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(KIND3_FOLLOWS), + val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(ALL_FOLLOWS), val defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), val defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), val defaultDiscoveryFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), - var zapPaymentRequest: Nip47WalletConnect.Nip47URI? = null, + var zapPaymentRequest: Nip47WalletConnect.Nip47URINorm? = null, var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, var hideNIP17WarningDialog: Boolean = false, @@ -128,6 +120,9 @@ class AccountSettings( var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null, var backupAppSpecificData: AppSpecificDataEvent? = null, var backupChannelList: ChannelListEvent? = null, + var backupCommunityList: CommunityListEvent? = null, + var backupHashtagList: HashtagListEvent? = null, + var backupGeohashList: GeohashListEvent? = null, var backupEphemeralChatList: EphemeralChatListEvent? = null, val torSettings: TorSettingsFlow = TorSettingsFlow(), val lastReadPerRoute: MutableStateFlow>> = MutableStateFlow(mapOf()), @@ -200,7 +195,7 @@ class AccountSettings( return false } - fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?): Boolean { + fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URINorm?): Boolean { if (zapPaymentRequest != newServer) { zapPaymentRequest = newServer saveAccountSettings() @@ -256,11 +251,11 @@ class AccountSettings( // proxy settings // --- fun setTorSettings(newTorSettings: TorSettings): Boolean { - if (torSettings.update(newTorSettings)) { + return if (torSettings.update(newTorSettings)) { saveAccountSettings() - return true + true } else { - return false + false } } @@ -301,8 +296,8 @@ class AccountSettings( // ---- fun updateLocalRelayServers(servers: Set) { - if (localRelayServers != servers) { - localRelayServers = servers + if (localRelayServers.value != servers) { + localRelayServers.update { servers } saveAccountSettings() } } @@ -377,6 +372,36 @@ class AccountSettings( } } + fun updateGeohashListTo(newGeohashList: GeohashListEvent?) { + if (newGeohashList == null || newGeohashList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupGeohashList?.id != newGeohashList.id) { + backupGeohashList = newGeohashList + saveAccountSettings() + } + } + + fun updateHashtagListTo(newHashtagList: HashtagListEvent?) { + if (newHashtagList == null || newHashtagList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupHashtagList?.id != newHashtagList.id) { + backupHashtagList = newHashtagList + saveAccountSettings() + } + } + + fun updateCommunityListTo(newCommunityList: CommunityListEvent?) { + if (newCommunityList == null || newCommunityList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupCommunityList?.id != newCommunityList.id) { + backupCommunityList = newCommunityList + saveAccountSettings() + } + } + fun updateEphemeralChatListTo(newEphemeralChatList: EphemeralChatListEvent?) { if (newEphemeralChatList == null || newEphemeralChatList.tags.isEmpty()) return @@ -492,17 +517,6 @@ class AccountSettings( } } - // ---- - // local relays - // ---- - - fun updateLocalRelays(newLocalRelays: Set) { - if (!localRelays.equals(newLocalRelays)) { - localRelays = newLocalRelays - saveAccountSettings() - } - } - // --- // attestations // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt index ed7d2b66d..c1c433c1c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -24,10 +24,10 @@ import android.util.Log import android.util.LruCache import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.note.njumpLink -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.ammolite.relays.RelayStats import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import kotlinx.coroutines.flow.MutableStateFlow @@ -45,7 +45,7 @@ class AntiSpamFilter { fun isSpam( event: Event, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, ): Boolean { checkNotInMainThread() @@ -74,14 +74,14 @@ class AntiSpamFilter { ) { Log.w( "Potential SPAM Message", - "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}", + "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} $relay ${event.content.replace("\n", " | ")}", ) // Log down offenders logOffender(hash, event) if (relay != null) { - RelayStats.newSpam(relay.url, njumpLink(NEvent.create(event.id, event.pubKey, event.kind, relay.url))) + RelayStats.newSpam(relay, njumpLink(NEvent.create(event.id, event.pubKey, event.kind, relay))) } flowSpam.tryEmit(AntiSpamState(this)) 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 fd604bfbe..b7ba6f7c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -23,11 +23,11 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.note.toShortenHex -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle -import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHintOptional +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList @@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.toNEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent -import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData +import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.LargeCache @@ -51,7 +51,7 @@ class EphemeralChatChannel( override fun idDisplayNote() = idNote().toShortenHex() - override fun relays() = listOf(roomId.relayUrl) + override fun relays() = setOf(roomId.relayUrl) override fun toBestDisplayName() = roomId.toDisplayKey() @@ -68,17 +68,21 @@ class PublicChatChannel( ) : Channel(idHex) { var event: ChannelCreateEvent? = null var infoTags = EmptyTagList - var info = ChannelData(null, null, null, null) + var info = ChannelDataNorm(null, null, null, null) - override fun relays() = info.relays ?: super.relays() + override fun relays() = info.relays?.toSet() ?: super.relays() - fun toNEvent() = NEvent.create(idHex, event?.pubKey, ChannelCreateEvent.KIND, *relays().toTypedArray()) + fun relayHintUrls() = relays().take(3) + + fun relayHintUrl() = relays().firstOrNull() + + fun toNEvent() = NEvent.create(idHex, event?.pubKey, ChannelCreateEvent.KIND, relayHintUrls()) fun toNostrUri() = "nostr:${toNEvent()}" - fun toEventHint() = event?.let { EventHintBundle(it, relays().firstOrNull(), null) } + fun toEventHint() = event?.let { EventHintBundle(it, relayHintUrl(), null) } - fun toEventId() = EventIdHint(idHex, relays().firstOrNull()) + fun toEventId() = EventIdHintOptional(idHex, relayHintUrl()) fun updateChannelInfo( creator: User, @@ -99,7 +103,7 @@ class PublicChatChannel( fun updateChannelInfo( creator: User, - channelInfo: ChannelData, + channelInfo: ChannelDataNorm, updatedAt: Long, ) { this.info = channelInfo @@ -130,10 +134,12 @@ class LiveActivitiesChannel( fun address() = address - override fun relays() = info?.allRelayUrls() ?: super.relays() + override fun relays() = info?.allRelayUrls()?.toSet() ?: super.relays() fun relayHintUrl() = relays().firstOrNull() + fun relayHintUrls() = relays().take(3) + fun updateChannelInfo( creator: User, channelInfo: LiveActivitiesEvent, @@ -150,11 +156,10 @@ class LiveActivitiesChannel( override fun profilePicture(): String? = info?.image()?.ifBlank { null } override fun anyNameStartsWith(prefix: String): Boolean = - listOfNotNull(info?.title(), info?.summary()) - .filter { it.contains(prefix, true) } - .isNotEmpty() + info?.title()?.contains(prefix, true) == true || + info?.summary()?.contains(prefix, true) == true - fun toNAddr() = NAddress.create(address.kind, address.pubKeyHex, address.dTag, *relays().toTypedArray()) + fun toNAddr() = NAddress.create(address.kind, address.pubKeyHex, address.dTag, relayHintUrls()) fun toATag() = ATag(address, relayHintUrl()) @@ -173,7 +178,8 @@ abstract class Channel( var updatedMetadataAt: Long = 0 val notes = LargeCache() var lastNoteCreatedAt: Long = 0 - private var relays = mapOf() + + private var relays = mapOf() open fun idNote() = Hex.decode(idHex).toNEvent() @@ -187,13 +193,13 @@ abstract class Channel( open fun profilePicture(): String? = creator?.info?.banner - open fun relays() = + open fun relays(): Set = relays.keys .toSortedSet { o1, o2 -> val o1Count = relays[o1]?.number ?: 0 val o2Count = relays[o2]?.number ?: 0 o2Count.compareTo(o1Count) // descending - }.map { it.url } + } open fun updateChannelInfo( creator: User, @@ -206,13 +212,13 @@ abstract class Channel( } @Synchronized - fun addRelaySync(briefInfo: RelayBriefInfoCache.RelayBriefInfo) { + fun addRelaySync(briefInfo: NormalizedRelayUrl) { if (briefInfo !in relays) { relays = relays + Pair(briefInfo, Counter(1)) } } - fun addRelay(relay: RelayBriefInfoCache.RelayBriefInfo) { + fun addRelay(relay: NormalizedRelayUrl) { val counter = relays[relay] if (counter != null) { counter.number++ @@ -223,7 +229,7 @@ abstract class Channel( fun addNote( note: Note, - relay: RelayBriefInfoCache.RelayBriefInfo? = null, + relay: NormalizedRelayUrl? = null, ) { notes.put(note.idHex, note) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt new file mode 100644 index 000000000..ec6a49a48 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 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.model + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +object Constants { + val nos = RelayUrlNormalizer.normalize("wss://nos.lol") + val mom = RelayUrlNormalizer.normalize("wss://nostr.mom") + val primal = RelayUrlNormalizer.normalize("wss://relay.primal.net") + val damus = RelayUrlNormalizer.normalize("wss://relay.damus.io") + val wine = RelayUrlNormalizer.normalize("wss://nostr.wine") + val band = RelayUrlNormalizer.normalize("wss://relay.nostr.band") + + val where = RelayUrlNormalizer.normalize("wss://relay.noswhere.com") + val elites = RelayUrlNormalizer.normalize("wss://nostrelites.org") + + val bitcoiner = RelayUrlNormalizer.normalize("wss://nostr.bitcoiner.social") + val bg = RelayUrlNormalizer.normalize("wss://relay.nostr.bg") + val oxtr = RelayUrlNormalizer.normalize("wss://nostr.oxtr.dev") + val fmtwiz = RelayUrlNormalizer.normalize("wss://nostr.fmt.wiz.biz") + + val nostoday = RelayUrlNormalizer.normalize("wss://search.nos.today") + + val auth = RelayUrlNormalizer.normalize("wss://auth.nostr1.com") + val oxchat = RelayUrlNormalizer.normalize("wss://relay.0xchat.com") + + val eventFinderRelays = setOf(band, wine, damus, primal, mom, nos, bitcoiner, oxtr, fmtwiz, bg) + + val defaultSearchRelaySet = setOf(band, wine, where) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt index 421462e60..a78f944ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -50,19 +50,21 @@ import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment import com.vitorpamplona.amethyst.ui.components.HashTag import com.vitorpamplona.amethyst.ui.components.RenderRegular import com.vitorpamplona.amethyst.ui.navigation.EmptyNav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList @Preview @Composable fun RenderHashTagIconsPreview() { + val accountViewModel = mockAccountViewModel() ThemeComparisonColumn { RenderRegular( "Testing rendering of hashtags: #flowerstr #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate, #gamestr, #gamechain", EmptyTagList, ) { word, state -> when (word) { - is HashTagSegment -> HashTag(word, EmptyNav) + is HashTagSegment -> HashTag(word, accountViewModel, EmptyNav) is RegularTextSegment -> Text(word.segmentText) } } 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 582502903..a2977894f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -20,19 +20,16 @@ */ package com.vitorpamplona.amethyst.model -import android.R.attr.version import android.util.Log import android.util.LruCache import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.note.dateFormatter import com.vitorpamplona.ammolite.relays.BundledInsert -import com.vitorpamplona.ammolite.relays.Relay -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.quartz.blossom.BlossomServersEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent @@ -56,7 +53,15 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.tagValueContains +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.addressables.mapTaggedAddress @@ -86,6 +91,14 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.entities.Entity +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.isATag import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -118,6 +131,8 @@ import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent @@ -135,13 +150,13 @@ import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityListEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent +import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -154,6 +169,7 @@ import com.vitorpamplona.quartz.nip90Dvms.NIP90UserDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.LargeCache import com.vitorpamplona.quartz.utils.TimeUtils @@ -175,11 +191,13 @@ import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.concurrent.ConcurrentHashMap +import kotlin.compareTo +import kotlin.text.get interface ILocalCache { fun markAsSeen( - string: String, - relay: RelayBriefInfoCache.RelayBriefInfo, + eventId: String, + relay: NormalizedRelayUrl, ) {} } @@ -188,10 +206,16 @@ object LocalCache : ILocalCache { val users = LargeCache() val notes = LargeCache() - val addressables = LargeCache() - val channels = LargeCache() + val addressables = LargeCache() + + val publicChatChannels = LargeCache() + val liveChatChannels = LargeCache() + val ephemeralChannels = LargeCache() + val awaitingPaymentRequests = ConcurrentHashMap Unit>>(10) + val relayHints = HintIndexer() + val deletionIndex = DeletionIndex() val observablesByKindAndETag = ConcurrentHashMap>>(10) @@ -287,17 +311,19 @@ object LocalCache : ILocalCache { return users.get(key) } - fun getAddressableNoteIfExists(key: String): AddressableNote? = addressables.get(key) + fun getAddressableNoteIfExists(key: String): AddressableNote? = Address.parse(key)?.let { addressables.get(it) } - fun getAddressableNoteIfExists(address: Address): AddressableNote? = getAddressableNoteIfExists(address.toValue()) + fun getAddressableNoteIfExists(address: Address): AddressableNote? = getAddressableNoteIfExists(address) - fun getNoteIfExists(key: String): Note? = addressables.get(key) ?: notes.get(key) + fun getNoteIfExists(key: String): Note? = if (key.length == 64) notes.get(key) else Address.parse(key)?.let { addressables.get(it) } fun getNoteIfExists(key: ETag): Note? = notes.get(key.eventId) - fun getChannelIfExists(key: String): Channel? = channels.get(key) + fun getChannelIfExists(key: String): PublicChatChannel? = publicChatChannels.get(key) - fun getChannelIfExists(key: RoomId): Channel? = channels.get(key.toKey()) + fun getChannelIfExists(key: RoomId): EphemeralChatChannel? = ephemeralChannels.get(key) + + fun getChannelIfExists(key: Address): LiveActivitiesChannel? = liveChatChannels.get(key) fun getNoteIfExists(event: Event): Note? = if (event is AddressableEvent) { @@ -372,17 +398,18 @@ object LocalCache : ILocalCache { } } - fun getOrCreateChannel( - key: String, - channelFactory: (String) -> Channel, - ): Channel { - checkNotInMainThread() + fun getOrCreatePublicChatChannel(key: HexKey): PublicChatChannel = + publicChatChannels.getOrCreate(key) { + PublicChatChannel(key) + } - return channels.getOrCreate(key, channelFactory) - } + fun getOrCreateLiveChannel(key: Address): LiveActivitiesChannel = + liveChatChannels.getOrCreate(key) { + LiveActivitiesChannel(key) + } - fun checkGetOrCreateChannel(key: RoomId): Channel? = - channels.getOrCreate(key.toKey()) { + fun getOrCreateEphemeralChannel(key: RoomId): EphemeralChatChannel = + ephemeralChannels.getOrCreate(key) { EphemeralChatChannel(key) } @@ -390,19 +417,23 @@ object LocalCache : ILocalCache { checkNotInMainThread() if (key.contains("@")) { - return channels.getOrCreate(key) { - val idParts = key.split("@") - EphemeralChatChannel(RoomId(idParts[0], idParts[1])) + val idParts = key.split("@") + val relay = RelayUrlNormalizer.normalizeOrNull(idParts[1]) + + if (relay == null) { + return null + } else { + getOrCreateEphemeralChannel(RoomId(idParts[0], relay)) } } if (isValidHex(key)) { - return channels.getOrCreate(key) { PublicChatChannel(key) } + return getOrCreatePublicChatChannel(key) } val address = Address.parse(key) if (address != null) { - return channels.getOrCreate(address.toValue()) { LiveActivitiesChannel(address) } + return getOrCreateLiveChannel(address) } return null } @@ -427,10 +458,7 @@ object LocalCache : ILocalCache { null } - fun getOrCreateAddressableNoteInternal(key: Address): AddressableNote = - addressables.getOrCreate(key.toValue()) { - AddressableNote(key) - } + fun getOrCreateAddressableNoteInternal(key: Address): AddressableNote = addressables.getOrCreate(key) { AddressableNote(key) } fun getOrCreateAddressableNote(key: Address): AddressableNote { val note = getOrCreateAddressableNoteInternal(key) @@ -449,16 +477,15 @@ object LocalCache : ILocalCache { note.author = checkGetOrCreateUser(possibleAuthor) } val relayHint = key.relay - if (!relayHint.isNullOrBlank()) { - val relay = RelayBriefInfoCache.get(RelayUrlFormatter.normalize(relayHint)) - note.addRelay(relay) + if (relayHint != null) { + note.addRelay(relayHint) } return note } fun consume( event: MetadataEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { // new event @@ -473,8 +500,8 @@ object LocalCache : ILocalCache { oldUser.updateUserInfo(newUserMetadata, event) if (relay != null) { oldUser.addRelayBeingUsed(relay, event.createdAt) - if (!RelayUrlFormatter.isLocalHost(relay.url)) { - oldUser.latestMetadataRelay = relay.url + if (!relay.isLocalHost()) { + oldUser.latestMetadataRelay = relay } } @@ -487,7 +514,7 @@ object LocalCache : ILocalCache { fun consume( event: ContactListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val user = getOrCreateUser(event.pubKey) @@ -507,7 +534,7 @@ object LocalCache : ILocalCache { fun consume( event: BookmarkListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val user = getOrCreateUser(event.pubKey) @@ -531,37 +558,37 @@ object LocalCache : ILocalCache { fun consume( event: TextNoteEvent, - relay: RelayBriefInfoCache.RelayBriefInfo? = null, + relay: NormalizedRelayUrl? = null, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: TorrentEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: InteractiveStoryPrologueEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: InteractiveStorySceneEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: InteractiveStoryReadingStateEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consumeRegularEvent( event: Event, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -597,67 +624,73 @@ object LocalCache : ILocalCache { fun consume( event: PictureEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: TorrentCommentEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: NIP90ContentDiscoveryResponseEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: NIP90ContentDiscoveryRequestEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: NIP90StatusEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: NIP90UserDiscoveryResponseEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: NIP90UserDiscoveryRequestEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + + fun consume( + event: GoalEvent, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: GitPatchEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: GitIssueEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: GitReplyEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: LongTextNoteEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val version = getOrCreateNote(event.id) @@ -702,7 +735,7 @@ object LocalCache : ILocalCache { fun consume( event: WikiNoteEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val version = getOrCreateNote(event.id) @@ -806,13 +839,13 @@ object LocalCache : ILocalCache { fun consume( event: PollNoteEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) private fun consume( event: LiveActivitiesEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val version = getOrCreateNote(event.id) @@ -833,15 +866,15 @@ object LocalCache : ILocalCache { if (event.createdAt > (note.createdAt() ?: 0) && (isVerified || justVerify(event))) { note.loadEvent(event, author, emptyList()) - val channel = getOrCreateChannel(note.idHex) { LiveActivitiesChannel(note.address) } as? LiveActivitiesChannel + val channel = getOrCreateLiveChannel(note.address) if (relay != null) { - channel?.addRelay(relay) + channel.addRelay(relay) } val creator = event.host()?.let { checkGetOrCreateUser(it.pubKey) } ?: author - channel?.updateChannelInfo(creator, event, event.createdAt) + channel.updateChannelInfo(creator, event, event.createdAt) refreshObservers(note) @@ -853,139 +886,151 @@ object LocalCache : ILocalCache { fun consume( event: MuteListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: CommunityListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: GitRepositoryEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: ChannelListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: BlossomServersEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: FileServersEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: PeopleListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: EphemeralChatListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: FollowListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: AdvertisedRelayListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: ChatMessageRelayListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: PrivateOutboxRelayListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + private fun consume( + event: HashtagListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + private fun consume( + event: GeohashListEvent, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: SearchRelayListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: CommunityDefinitionEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: EmojiPackSelectionEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: EmojiPackEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: ClassifiedsEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: PinListEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: RelaySetEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: AudioTrackEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: VideoVerticalEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: VideoHorizontalEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: StatusEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val version = getOrCreateNote(event.id) @@ -1019,13 +1064,13 @@ object LocalCache : ILocalCache { fun consume( event: RelationshipStatusEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: OtsEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val version = getOrCreateNote(event.id) @@ -1049,61 +1094,61 @@ object LocalCache : ILocalCache { fun consume( event: BadgeDefinitionEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: BadgeProfilesEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: BadgeAwardEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) private fun consume( event: NNSEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: AppDefinitionEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: CalendarEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: CalendarDateSlotEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: CalendarTimeSlotEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consume( event: CalendarRSVPEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consumeBaseReplaceable( event: BaseAddressableEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val version = getOrCreateNote(event.id) @@ -1140,19 +1185,19 @@ object LocalCache : ILocalCache { fun consume( event: AppRecommendationEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: AppSpecificDataEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) fun consume( event: PrivateDmEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1190,7 +1235,7 @@ object LocalCache : ILocalCache { fun consume( event: DeletionEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { if (deletionIndex.add(event, wasVerified)) { @@ -1207,7 +1252,7 @@ object LocalCache : ILocalCache { // Counts the replies deleteNote(addressableNote) - addressables.remove(addressableNote.idHex) + addressables.remove(addressableNote.address) deletedAtLeastOne = true } @@ -1233,7 +1278,7 @@ object LocalCache : ILocalCache { // Counts the replies deleteNote(deleteNote) - addressables.remove(deleteNote.idHex) + addressables.remove(deleteNote.address) deletedAtLeastOne = true } @@ -1340,7 +1385,7 @@ object LocalCache : ILocalCache { fun consume( event: RepostEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1358,7 +1403,7 @@ object LocalCache : ILocalCache { repliesTo.forEach { it.addBoost(note) } event.containedPost()?.let { - justConsumeInner(it, relay, false) + justConsumeAndUpdateIndexes(it, relay, false) } refreshObservers(note) @@ -1370,7 +1415,7 @@ object LocalCache : ILocalCache { fun consume( event: GenericRepostEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1388,7 +1433,7 @@ object LocalCache : ILocalCache { repliesTo.forEach { it.addBoost(note) } event.containedPost()?.let { - justConsumeInner(it, relay, false) + justConsumeAndUpdateIndexes(it, relay, false) } refreshObservers(note) @@ -1401,7 +1446,7 @@ object LocalCache : ILocalCache { fun consume( event: CommunityPostApprovalEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1423,7 +1468,7 @@ object LocalCache : ILocalCache { repliesTo.forEach { it.addBoost(note) } event.containedPost()?.let { - justConsumeInner(it, relay, false) + justConsumeAndUpdateIndexes(it, relay, false) } refreshObservers(note) @@ -1436,7 +1481,7 @@ object LocalCache : ILocalCache { fun consume( event: ReactionEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1462,7 +1507,7 @@ object LocalCache : ILocalCache { fun consume( event: ReportEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1506,11 +1551,11 @@ object LocalCache : ILocalCache { fun consume( event: ChannelCreateEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { // Log.d("MT", "New Event ${event.content} ${event.id.toHex()}") - val oldChannel = getOrCreateChannel(event.id) { PublicChatChannel(it) } + val oldChannel = getOrCreatePublicChatChannel(event.id) val author = getOrCreateUser(event.pubKey) val note = getOrCreateNote(event.id) @@ -1530,7 +1575,7 @@ object LocalCache : ILocalCache { } if (oldChannel.creator == null || oldChannel.creator == author) { - if (oldChannel is PublicChatChannel && (isVerified || justVerify(event))) { + if (isVerified || justVerify(event)) { oldChannel.updateChannelInfo(author, event) } } @@ -1540,7 +1585,7 @@ object LocalCache : ILocalCache { fun consume( event: ChannelMetadataEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val channelId = event.channelId() @@ -1575,7 +1620,7 @@ object LocalCache : ILocalCache { fun consume( event: ChannelMessageEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val channelId = event.channelId() @@ -1620,14 +1665,14 @@ object LocalCache : ILocalCache { fun consume( event: EphemeralChatEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { - val relayUrl = event.relay().ifBlank { return false } + val roomId = event.roomId() + if (roomId == null) return false - val channelId = RoomId(event.room(), relayUrl) - - val channel = checkGetOrCreateChannel(channelId) ?: return false + val channelId = roomId + val channel = getOrCreateEphemeralChannel(channelId) ?: return false val note = getOrCreateNote(event.id) channel.addNote(note, relay) @@ -1659,18 +1704,18 @@ object LocalCache : ILocalCache { fun consume( event: CommentEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: LiveActivitiesChatMessageEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val activityAddress = event.activityAddress() ?: return false - val channel = getOrCreateChannel(activityAddress.toValue()) { LiveActivitiesChannel(activityAddress) } + val channel = getOrCreateLiveChannel(activityAddress) val note = getOrCreateNote(event.id) channel.addNote(note, relay) @@ -1708,20 +1753,20 @@ object LocalCache : ILocalCache { @Suppress("UNUSED_PARAMETER") fun consume( event: ChannelHideMessageEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean = false @Suppress("UNUSED_PARAMETER") fun consume( event: ChannelMuteUserEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean = false fun consume( event: LnZapEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1734,7 +1779,7 @@ object LocalCache : ILocalCache { if (existingZapRequest == null || existingZapRequest.event == null) { // tries to add it event.zapRequest?.let { - justConsumeInner(it, relay, false) + justConsumeAndUpdateIndexes(it, relay, false) } } @@ -1764,7 +1809,7 @@ object LocalCache : ILocalCache { fun consume( event: LnZapRequestEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1792,37 +1837,37 @@ object LocalCache : ILocalCache { fun consume( event: AudioHeaderEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: FileHeaderEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: ProfileGalleryEntryEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: FileStorageHeaderEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: FhirResourceEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: TextNoteModificationEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1857,13 +1902,13 @@ object LocalCache : ILocalCache { fun consume( event: HighlightEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) fun consume( event: FileStorageEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1885,7 +1930,7 @@ object LocalCache : ILocalCache { stream.close() Log.i( "FileStorageEvent", - "NIP95 File received from ${relay?.url} and saved to disk as $file", + "NIP95 File received from $relay and saved to disk as $file", ) true } else { @@ -1916,7 +1961,7 @@ object LocalCache : ILocalCache { private fun consume( event: ChatMessageEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -1966,7 +2011,7 @@ object LocalCache : ILocalCache { private fun consume( event: ChatMessageEncryptedFileHeaderEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -2016,7 +2061,7 @@ object LocalCache : ILocalCache { fun consume( event: SealedRumorEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -2041,7 +2086,7 @@ object LocalCache : ILocalCache { fun consume( event: GiftWrapEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) @@ -2065,7 +2110,7 @@ object LocalCache : ILocalCache { fun consume( event: LnZapPaymentRequestEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { // Does nothing without a response callback. @@ -2076,6 +2121,7 @@ object LocalCache : ILocalCache { event: LnZapPaymentRequestEvent, zappedNote: Note?, wasVerified: Boolean, + relay: NormalizedRelayUrl?, onResponse: (LnZapPaymentResponseEvent) -> Unit, ): Boolean { val note = getOrCreateNote(event.id) @@ -2087,6 +2133,10 @@ object LocalCache : ILocalCache { if (wasVerified || justVerify(event)) { note.loadEvent(event, author, emptyList()) + relay?.let { + note.addRelay(relay) + } + zappedNote?.addZapPayment(note, null) awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse)) @@ -2101,7 +2151,7 @@ object LocalCache : ILocalCache { fun consume( event: LnZapPaymentResponseEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val requestId = event.requestId() @@ -2154,7 +2204,7 @@ object LocalCache : ILocalCache { user.pubkeyHex.startsWith(username, true) || user.pubkeyNpub().startsWith(username, true) ) && - (forAccount == null || (!forAccount.isHidden(user) && !user.containsAny(forAccount.flowHiddenUsers.value.hiddenWordsCase))) + (forAccount == null || (!forAccount.isHidden(user) && !user.containsAny(forAccount.hiddenUsers.flow.value.hiddenWordsCase))) } return finds.sortedWith( @@ -2184,7 +2234,7 @@ object LocalCache : ILocalCache { fun findNotesStartingWith( text: String, - forAccount: Account, + hiddenUsers: HiddenUsersState, ): List { checkNotInMainThread() @@ -2207,7 +2257,7 @@ object LocalCache : ILocalCache { if (note.event?.tags?.tagValueContains(text, true) == true || note.idHex.startsWith(text, true) ) { - if (!note.isHiddenFor(forAccount.flowHiddenUsers.value)) { + if (!note.isHiddenFor(hiddenUsers.flow.value)) { return@filter true } else { return@filter false @@ -2215,7 +2265,7 @@ object LocalCache : ILocalCache { } if (note.event?.isContentEncoded() == false) { - if (!note.isHiddenFor(forAccount.flowHiddenUsers.value)) { + if (!note.isHiddenFor(hiddenUsers.flow.value)) { return@filter note.event?.content?.contains(text, true) ?: false } else { return@filter false @@ -2232,7 +2282,7 @@ object LocalCache : ILocalCache { if (addressable.event?.tags?.tagValueContains(text, true) == true || addressable.idHex.startsWith(text, true) ) { - if (!addressable.isHiddenFor(forAccount.flowHiddenUsers.value)) { + if (!addressable.isHiddenFor(hiddenUsers.flow.value)) { return@filter true } else { return@filter false @@ -2240,7 +2290,7 @@ object LocalCache : ILocalCache { } if (addressable.event?.isContentEncoded() == false) { - if (!addressable.isHiddenFor(forAccount.flowHiddenUsers.value)) { + if (!addressable.isHiddenFor(hiddenUsers.flow.value)) { return@filter addressable.event?.content?.contains(text, true) ?: false } else { return@filter false @@ -2261,11 +2311,21 @@ object LocalCache : ILocalCache { return listOfNotNull(getChannelIfExists(key)) } - return channels.filter { _, channel -> + return publicChatChannels.filter { _, channel -> channel.anyNameStartsWith(text) || channel.idHex.startsWith(text, true) || channel.idNote().startsWith(text, true) - } + } + + ephemeralChannels.filter { _, channel -> + channel.anyNameStartsWith(text) || + channel.idHex.startsWith(text, true) || + channel.idNote().startsWith(text, true) + } + + liveChatChannels.filter { _, channel -> + channel.anyNameStartsWith(text) || + channel.idHex.startsWith(text, true) || + channel.idNote().startsWith(text, true) + } } suspend fun findStatusesForUser(user: User): ImmutableList { @@ -2358,49 +2418,76 @@ object LocalCache : ILocalCache { } } + fun pruneHiddenMessagesChannel( + channel: Channel, + account: Account, + ) { + val toBeRemoved = channel.pruneHiddenMessages(account) + + 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} hidden messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept", + ) + } + } + fun pruneHiddenMessages(account: Account) { - channels.forEach { _, channel -> - val toBeRemoved = channel.pruneHiddenMessages(account) + ephemeralChannels.forEach { _, channel -> + pruneHiddenMessagesChannel(channel, account) + } - val childrenToBeRemoved = mutableListOf() + liveChatChannels.forEach { _, channel -> + pruneHiddenMessagesChannel(channel, account) + } - toBeRemoved.forEach { - removeFromCache(it) + publicChatChannels.forEach { _, channel -> + pruneHiddenMessagesChannel(channel, account) + } + } - childrenToBeRemoved.addAll(it.removeAllChildNotes()) - } + fun pruneOldMessagesChannel(channel: Channel) { + val toBeRemoved = channel.pruneOldMessages() - removeFromCache(childrenToBeRemoved) + val childrenToBeRemoved = mutableListOf() - if (toBeRemoved.size > 100 || channel.notes.size() > 100) { - println( - "PRUNE: ${toBeRemoved.size} hidden messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept", - ) - } + 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", + ) } } fun pruneOldMessages() { checkNotInMainThread() - channels.forEach { _, channel -> - val toBeRemoved = channel.pruneOldMessages() + ephemeralChannels.forEach { _, channel -> + pruneOldMessagesChannel(channel) + } - val childrenToBeRemoved = mutableListOf() + liveChatChannels.forEach { _, channel -> + pruneOldMessagesChannel(channel) + } - 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", - ) - } + publicChatChannels.forEach { _, channel -> + pruneOldMessagesChannel(channel) } users.forEach { _, user -> @@ -2452,7 +2539,7 @@ object LocalCache : ILocalCache { val noteEvent = note.event if (noteEvent is AddressableEvent) { noteEvent.createdAt < - (addressables.get(noteEvent.aTag().toTag())?.event?.createdAt ?: 0) + (addressables.get(noteEvent.address())?.event?.createdAt ?: 0) } else { false } @@ -2461,7 +2548,7 @@ object LocalCache : ILocalCache { val childrenToBeRemoved = mutableListOf() toBeRemoved.forEach { - val newerVersion = (it.event as? AddressableEvent)?.aTag()?.toTag()?.let { tag -> addressables.get(tag) } + val newerVersion = (it.event as? AddressableEvent)?.address()?.let { tag -> addressables.get(tag) } if (newerVersion != null) { it.moveAllReferencesTo(newerVersion) } @@ -2579,7 +2666,7 @@ object LocalCache : ILocalCache { val childrenToBeRemoved = mutableListOf() val toBeRemoved = - account.flowHiddenUsers.value.hiddenUsers + account.hiddenUsers.flow.value.hiddenUsers .map { userHex -> (notes.filter { _, it -> it.event?.pubKey == userHex } + addressables.filter { _, it -> it.event?.pubKey == userHex }).toSet() }.flatten() @@ -2614,7 +2701,7 @@ object LocalCache : ILocalCache { override fun markAsSeen( eventId: String, - relay: RelayBriefInfoCache.RelayBriefInfo, + relay: NormalizedRelayUrl, ) { val note = getNoteIfExists(eventId) @@ -2655,7 +2742,7 @@ object LocalCache : ILocalCache { fun consume( event: DraftEvent, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { if (!event.isDeleted()) { @@ -2722,7 +2809,9 @@ object LocalCache : ILocalCache { } } is EphemeralChatEvent -> { - checkGetOrCreateChannel(draft.roomId().toKey())?.addNote(note, null) + draft.roomId()?.toKey()?.let { + checkGetOrCreateChannel(it)?.addNote(note, null) + } } is ChannelMessageEvent -> { draft.channelId()?.let { channelId -> @@ -2802,7 +2891,9 @@ object LocalCache : ILocalCache { } } is EphemeralChatEvent -> { - checkGetOrCreateChannel(draft.roomId())?.removeNote(draftWrap) + draft.roomId()?.let { + getOrCreateEphemeralChannel(it).removeNote(draftWrap) + } } is TextNoteEvent -> { val replyTo = computeReplyTo(draft) @@ -2811,11 +2902,45 @@ object LocalCache : ILocalCache { } } - fun justConsumeMyOwnEvent(event: Event) = justConsumeInner(event, null, true) + fun consume(nip19: Entity) { + when (nip19) { + is NSec -> getOrCreateUser(nip19.toPubKeyHex()) + is NPub -> getOrCreateUser(nip19.hex) + is NProfile -> { + nip19.relay.forEach { relayHint -> + relayHints.addKey(nip19.hex, relayHint) + } + getOrCreateUser(nip19.hex) + } + is com.vitorpamplona.quartz.nip19Bech32.entities.Note -> { + getOrCreateNote(nip19.hex) + } + is NEvent -> { + nip19.relay.forEach { relayHint -> + relayHints.addEvent(nip19.hex, relayHint) + } + getOrCreateNote(nip19.hex) + } + is NEmbed -> { + justConsume(nip19.event, null, false) + } + is NRelay -> {} + is NAddress -> { + val aTag = nip19.aTag() + nip19.relay.forEach { relayHint -> + relayHints.addAddress(aTag, relayHint) + } + getOrCreateAddressableNote(nip19.address()) + } + else -> { } + } + } + + fun justConsumeMyOwnEvent(event: Event) = justConsumeAndUpdateIndexes(event, null, true) fun justConsume( event: Event, - relay: Relay?, + relay: IRelayClient?, wasVerified: Boolean, ): Boolean { if (deletionIndex.hasBeenDeleted(event)) { @@ -2833,7 +2958,7 @@ object LocalCache : ILocalCache { // updates relay with a new event. getAddressableNoteIfExists(event.addressTag())?.let { note -> note.event?.let { existingEvent -> - if (existingEvent.createdAt > event.createdAt && !note.hasRelay(relay.brief)) { + if (existingEvent.createdAt > event.createdAt && !note.hasRelay(relay.url)) { Log.d("LocalCache", "Updating ${relay.url} with a new version of ${event.toJson()} to ${existingEvent.toJson()}") relay.send(existingEvent) } @@ -2841,12 +2966,44 @@ object LocalCache : ILocalCache { } } - return justConsumeInner(event, relay?.brief, wasVerified) + return justConsumeAndUpdateIndexes(event, relay?.url, wasVerified) } - fun justConsumeInner( + private fun justConsumeAndUpdateIndexes( event: Event, - relay: RelayBriefInfoCache.RelayBriefInfo?, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val wasNew = justConsumeInnerInner(event, relay, wasVerified) + + if (wasNew && relay != null) { + updateHintIndexes(event) + } + + return wasNew + } + + fun updateHintIndexes(event: Event) { + if (event is EventHintProvider) { + event.eventHints().forEach { + relayHints.addEvent(it.eventId, it.relay) + } + } + if (event is AddressHintProvider) { + event.addressHints().forEach { + relayHints.addAddress(it.addressId, it.relay) + } + } + if (event is PubKeyHintProvider) { + event.pubKeyHints().forEach { + relayHints.addKey(it.pubkey, it.relay) + } + } + } + + private fun justConsumeInnerInner( + event: Event, + relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean = try { @@ -2895,11 +3052,14 @@ object LocalCache : ILocalCache { is FileStorageEvent -> consume(event, relay, wasVerified) is FileStorageHeaderEvent -> consume(event, relay, wasVerified) is FollowListEvent -> consume(event, relay, wasVerified) + is GeohashListEvent -> consume(event, relay, wasVerified) + is GoalEvent -> consume(event, relay, wasVerified) is GiftWrapEvent -> consume(event, relay, wasVerified) is GitIssueEvent -> consume(event, relay, wasVerified) is GitReplyEvent -> consume(event, relay, wasVerified) is GitPatchEvent -> consume(event, relay, wasVerified) is GitRepositoryEvent -> consume(event, relay, wasVerified) + is HashtagListEvent -> consume(event, relay, wasVerified) is HighlightEvent -> consume(event, relay, wasVerified) is InteractiveStoryPrologueEvent -> consume(event, relay, wasVerified) is InteractiveStorySceneEvent -> consume(event, relay, wasVerified) @@ -2954,7 +3114,7 @@ object LocalCache : ILocalCache { fun hasConsumed(notificationEvent: Event): Boolean = if (notificationEvent is AddressableEvent) { - val note = addressables.get(notificationEvent.addressTag()) + val note = addressables.get(notificationEvent.address()) val noteEvent = note?.event noteEvent != null && notificationEvent.createdAt <= noteEvent.createdAt } else { 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 97b7c0c7b..3cb637329 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -22,13 +22,11 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.launchAndWaitAll +import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState 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.toShortenHex -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache import com.vitorpamplona.quartz.experimental.bounties.addedRewardValue import com.vitorpamplona.quartz.experimental.bounties.hasAdditionalReward import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent @@ -37,6 +35,7 @@ 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.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @@ -74,6 +73,8 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.containsAny +import com.vitorpamplona.quartz.utils.launchAndWaitAll +import com.vitorpamplona.quartz.utils.tryAndWait import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -157,7 +158,7 @@ open class Note( var zapPayments = mapOf() private set - var relays = listOf() + var relays = listOf() private set fun id() = Hex.decode(idHex) @@ -183,14 +184,26 @@ open class Note( } } - fun relayHintUrl(): String? { + fun relayUrls(): List { + val authorRelay = author?.relayHints()?.ifEmpty { null } + + return authorRelay ?: relays + } + + fun relayUrlsForReactions(): List { + val authorRelay = author?.inboxRelays()?.ifEmpty { null } + + return authorRelay ?: relays + } + + fun relayHintUrl(): NormalizedRelayUrl? { val authorRelay = author?.latestMetadataRelay return if (relays.isNotEmpty()) { - if (authorRelay != null && relays.any { it.url == authorRelay }) { + if (authorRelay != null && relays.any { it == authorRelay }) { authorRelay } else { - relays.firstOrNull()?.url + relays.firstOrNull() } } else { null @@ -293,7 +306,7 @@ open class Note( zaps = mapOf() zapPayments = mapOf() zapsAmount = BigDecimal.ZERO - relays = listOf() + relays = listOf() if (repliesChanged) flowSet?.replies?.invalidateData() if (reactionsChanged) flowSet?.reactions?.invalidateData() @@ -445,17 +458,17 @@ open class Note( } @Synchronized - fun addRelaySync(briefInfo: RelayBriefInfoCache.RelayBriefInfo) { - if (briefInfo !in relays) { - relays = relays + briefInfo + fun addRelaySync(relay: NormalizedRelayUrl) { + if (relay !in relays) { + relays = relays + relay } } - fun hasRelay(relay: RelayBriefInfoCache.RelayBriefInfo) = relay !in relays + fun hasRelay(relay: NormalizedRelayUrl) = relay !in relays - fun addRelay(brief: RelayBriefInfoCache.RelayBriefInfo) { - if (brief !in relays) { - addRelaySync(brief) + fun addRelay(relay: NormalizedRelayUrl) { + if (relay !in relays) { + addRelaySync(relay) flowSet?.relays?.invalidateData() } } @@ -779,9 +792,17 @@ open class Note( fun reactedBy(loggedIn: User): List = reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key } fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean { - return boosts.firstOrNull { - it.author == loggedIn && (it.createdAt() ?: 0) > TimeUtils.fiveMinutesAgo() - } != null // 5 minute protection + val fiveMinsAgo = TimeUtils.fiveMinutesAgo() + return boosts.any { + it.author == loggedIn && (it.createdAt() ?: 0) > fiveMinsAgo + } + } + + fun hasBoostedInTheLast5Minutes(loggedIn: HexKey): Boolean { + val fiveMinsAgo = TimeUtils.fiveMinutesAgo() + return boosts.any { + (it.createdAt() ?: 0) > fiveMinsAgo && it.author?.pubkeyHex == loggedIn + } } fun boostedBy(loggedIn: User): List = boosts.filter { it.author == loggedIn } @@ -823,7 +844,7 @@ open class Note( zapsAmount = BigDecimal.ZERO } - fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean { + fun isHiddenFor(accountChoices: HiddenUsersState.LiveHiddenUsers): Boolean { val thisEvent = event ?: return false val hash = thisEvent.pubKey.hashCode() 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 8d2165304..c633876ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -24,11 +24,11 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.note.toShortenHex -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache.RelayBriefInfo import com.vitorpamplona.quartz.lightning.Lud06 import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent @@ -57,7 +58,7 @@ class User( var info: UserMetadata? = null var latestMetadata: MetadataEvent? = null - var latestMetadataRelay: String? = null + var latestMetadataRelay: NormalizedRelayUrl? = null var latestContactList: ContactListEvent? = null var latestBookmarkList: BookmarkListEvent? = null @@ -67,7 +68,7 @@ class User( var zaps = mapOf() private set - var relaysBeingUsed = mapOf() + var relaysBeingUsed = mapOf() private set var privateChatrooms = mapOf() @@ -79,13 +80,21 @@ class User( fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex() + fun dmInboxRelayList() = (LocalCache.getAddressableNoteIfExists(ChatMessageRelayListEvent.createAddressTag(pubkeyHex))?.event as? ChatMessageRelayListEvent) + fun authorRelayList() = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(pubkeyHex))?.event as? AdvertisedRelayListEvent) fun toNProfile() = NProfile.create(pubkeyHex, relayHints()) - fun relayHints() = authorRelayList()?.writeRelays()?.take(3) ?: listOfNotNull(latestMetadataRelay) + fun outboxRelays() = authorRelayList()?.writeRelaysNorm() ?: listOfNotNull(latestMetadataRelay) - fun bestRelayHint() = authorRelayList()?.writeRelays()?.firstOrNull() ?: latestMetadataRelay + fun relayHints() = authorRelayList()?.writeRelaysNorm()?.take(3) ?: listOfNotNull(latestMetadataRelay) + + fun inboxRelays() = authorRelayList()?.readRelaysNorm() ?: listOfNotNull(latestMetadataRelay) + + fun dmInboxRelays() = dmInboxRelayList()?.relays()?.ifEmpty { null } ?: inboxRelays() + + fun bestRelayHint() = authorRelayList()?.writeRelaysNorm()?.firstOrNull() ?: latestMetadataRelay fun toPTag() = PTag(pubkeyHex, bestRelayHint()) @@ -289,12 +298,12 @@ class User( } fun addRelayBeingUsed( - relay: RelayBriefInfo, + relay: NormalizedRelayUrl, eventTime: Long, ) { - val here = relaysBeingUsed[relay.url] + val here = relaysBeingUsed[relay] if (here == null) { - relaysBeingUsed = relaysBeingUsed + Pair(relay.url, RelayInfo(relay.url, eventTime, 1)) + relaysBeingUsed = relaysBeingUsed + Pair(relay, RelayInfo(relay, eventTime, 1)) } else { if (eventTime > here.lastEvent) { here.lastEvent = eventTime @@ -345,8 +354,7 @@ class User( type: ReportType, ): Boolean = reports[loggedIn]?.firstOrNull { - it.event is ReportEvent && - (it.event as ReportEvent).reportedAuthor().any { it.type == type } + (it.event as? ReportEvent)?.reportedAuthor()?.any { it.type == type } ?: false } != null fun containsAny(hiddenWordsCase: List): Boolean { @@ -445,7 +453,7 @@ class UserFlowSet( @Immutable data class RelayInfo( - val url: String, + val url: NormalizedRelayUrl, var lastEvent: Long, var counter: Long, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListState.kt new file mode 100644 index 000000000..4cf071b46 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/edits/PrivateStorageRelayListState.kt @@ -0,0 +1,117 @@ +/** + * Copyright (c) 2025 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.model.edits + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class PrivateStorageRelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getPrivateOutboxRelayListAddress() = PrivateOutboxRelayListEvent.createAddress(signer.pubKey) + + fun getPrivateOutboxRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getPrivateOutboxRelayListAddress()) + + fun getPrivateOutboxRelayListFlow(): StateFlow = getPrivateOutboxRelayListNote().flow().metadata.stateFlow + + fun getPrivateOutboxRelayList(): PrivateOutboxRelayListEvent? = getPrivateOutboxRelayListNote().event as? PrivateOutboxRelayListEvent + + fun normalizePrivateOutboxRelayListWithBackup(note: Note): Set { + val event = note.event as? PrivateOutboxRelayListEvent ?: settings.backupPrivateHomeRelayList + return event?.relays()?.toSet() ?: emptySet() + } + + val flow = + getPrivateOutboxRelayListFlow() + .map { normalizePrivateOutboxRelayListWithBackup(it.note) } + .onStart { emit(normalizePrivateOutboxRelayListWithBackup(getPrivateOutboxRelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + fun saveRelayList( + relays: List, + onDone: (PrivateOutboxRelayListEvent) -> Unit, + ) { + val relayListForPrivateOutbox = getPrivateOutboxRelayList() + + if (relayListForPrivateOutbox != null && !relayListForPrivateOutbox.cachedPrivateTags().isNullOrEmpty()) { + PrivateOutboxRelayListEvent.updateRelayList( + earlierVersion = relayListForPrivateOutbox, + relays = relays, + signer = signer, + onReady = onDone, + ) + } else { + PrivateOutboxRelayListEvent.createFromScratch( + relays = relays, + signer = signer, + onReady = onDone, + ) + } + } + + init { + settings.backupPrivateHomeRelayList?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved private home relay list ${event.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + event.privateTags(signer) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Private Home Relay List Collector Start") + getPrivateOutboxRelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Private Home Relay List for ${signer.pubKey}") + (it.note.event as? PrivateOutboxRelayListEvent)?.let { + settings.updatePrivateHomeRelayList(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListState.kt index 8cf9ecfb5..52f9422d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/emphChat/EphemeralChatListState.kt @@ -20,29 +20,36 @@ */ package com.vitorpamplona.amethyst.model.emphChat +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.EphemeralChatChannel import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState -import com.vitorpamplona.amethyst.tryAndWait import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.tryAndWait import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch import kotlin.coroutines.resume class EphemeralChatListState( val signer: NostrSigner, val cache: LocalCache, val scope: CoroutineScope, + val settings: AccountSettings, ) { fun getEphemeralChatListAddress() = EphemeralChatListEvent.createAddress(signer.pubKey) @@ -52,31 +59,27 @@ class EphemeralChatListState( fun getEphemeralChatList(): EphemeralChatListEvent? = getEphemeralChatListNote().event as? EphemeralChatListEvent + suspend fun ephemeralChatListWithBackup(note: Note): Set { + return ephemeralChatList( + note.event as? EphemeralChatListEvent ?: settings.backupEphemeralChatList, + ) + } + + suspend fun ephemeralChatList(event: EphemeralChatListEvent?): Set { + return tryAndWait { continuation -> + event?.publicAndPrivateRoomIds(signer) { + continuation.resume(it) + } + } ?: emptySet() + } + @OptIn(ExperimentalCoroutinesApi::class) val liveEphemeralChatList: StateFlow> by lazy { getEphemeralChatListFlow() .transformLatest { noteState -> - val set = - tryAndWait { continuation -> - (noteState.note.event as? EphemeralChatListEvent)?.publicAndPrivateRoomIds(signer) { - continuation.resume(it) - } - } - - if (set != null) { - emit(set) - } + emit(ephemeralChatListWithBackup(noteState.note)) }.onStart { - val set = - tryAndWait { continuation -> - getEphemeralChatList()?.publicAndPrivateRoomIds(signer) { - continuation.resume(it) - } - } - - if (set != null) { - emit(set) - } + emit(ephemeralChatListWithBackup(getEphemeralChatListNote())) }.flowOn(Dispatchers.Default) .stateIn( scope, @@ -89,6 +92,7 @@ class EphemeralChatListState( channel: EphemeralChatChannel, onDone: (EphemeralChatListEvent) -> Unit, ) { + if (!signer.isWriteable()) return val ephemeralChatList = getEphemeralChatList() if (ephemeralChatList == null) { @@ -113,6 +117,7 @@ class EphemeralChatListState( channel: EphemeralChatChannel, onDone: (EphemeralChatListEvent) -> Unit, ) { + if (!signer.isWriteable()) return val ephemeralChatList = getEphemeralChatList() if (ephemeralChatList != null) { @@ -125,4 +130,26 @@ class EphemeralChatListState( ) } } + + init { + settings.backupEphemeralChatList?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved ephemeral chat list") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + event.privateTags(signer) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "EphemeralChatList Collector Start") + getEphemeralChatListFlow().collect { + Log.d("AccountRegisterObservers", "EphemeralChatList List for ${signer.pubKey}") + (it.note.event as? EphemeralChatListEvent)?.let { + settings.updateEphemeralChatListTo(it) + } + } + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/localRelays/LocalRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/localRelays/LocalRelayListState.kt new file mode 100644 index 000000000..88a49482e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/localRelays/LocalRelayListState.kt @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2025 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.model.localRelays + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +class LocalRelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun normalizeLocalRelayListWithBackup(relayList: Set): Set { + return relayList.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + } + + val flow = + settings.localRelayServers + .map { normalizeLocalRelayListWithBackup(it) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + normalizeLocalRelayListWithBackup(settings.localRelayServers.value), + ) + + fun saveRelayList( + relays: List, + onDone: () -> Unit, + ) { + settings.updateLocalRelayServers(relays.map { it.url }.toSet()) + onDone() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/AccountOutboxRelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/AccountOutboxRelayState.kt new file mode 100644 index 000000000..8db6d0c8d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/AccountOutboxRelayState.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2025 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.model.nip01UserMetadata + +import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState +import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState +import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlin.collections.plus + +class AccountOutboxRelayState( + nip65: Nip65RelayListState, + privateStorage: PrivateStorageRelayListState, + local: LocalRelayListState, + scope: CoroutineScope, +) { + val flow = + combine( + nip65.outboxFlow, + privateStorage.flow, + local.flow, + ) { nip65Inbox, privateOutBox, localRelays -> + nip65Inbox + privateOutBox + localRelays + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + nip65.outboxFlow.value + + privateStorage.flow.value + + local.flow.value, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/NotificationInboxRelayState.kt similarity index 61% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayManager.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/NotificationInboxRelayState.kt index 0200cb0e5..1d05dee9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/NotificationInboxRelayState.kt @@ -18,39 +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.relays +package com.vitorpamplona.amethyst.model.nip01UserMetadata -import android.app.Application -import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager -import com.vitorpamplona.amethyst.ui.tor.TorManager +import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState +import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn -/** - * There should be only one instance of the Tor binding per app. - * - * Tor will connect as soon as status is listened to. - */ -class RelayManager( - app: Application, +class NotificationInboxRelayState( + nip65RelayList: Nip65RelayListState, + localRelayList: LocalRelayListState, scope: CoroutineScope, - torManager: TorManager, - connManager: ConnectivityManager, ) { - val relayService = + val flow = combine( - torManager.status, - connManager.status, - ) { torStatus, connManager -> - } - - val status: StateFlow = - RelayService(app).status.stateIn( - scope, - SharingStarted.WhileSubscribed(30000), - RelayServiceStatus.Off, - ) + nip65RelayList.inboxFlow, + localRelayList.flow, + ) { nip65Inbox, localRelays -> + nip65Inbox + localRelays + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + nip65RelayList.inboxFlow.value + localRelayList.flow.value, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt new file mode 100644 index 000000000..dc4e9a2c9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt @@ -0,0 +1,124 @@ +/** + * Copyright (c) 2025 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.model.nip01UserMetadata + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.UserState +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class UserMetadataState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + // fun getEphemeralChatListAddress() = cache.getOrCreateUser(signer.pubKey) + + fun getUserMetadataUser(): User = cache.getOrCreateUser(signer.pubKey) + + fun getUserMetadataFlow(): StateFlow = getUserMetadataUser().flow().metadata.stateFlow + + fun getUserMetadataEvent(): MetadataEvent? = getUserMetadataUser().latestMetadata + + fun sendNewUserMetadata( + name: String? = null, + picture: String? = null, + banner: String? = null, + website: String? = null, + pronouns: String? = null, + about: String? = null, + nip05: String? = null, + lnAddress: String? = null, + lnURL: String? = null, + twitter: String? = null, + mastodon: String? = null, + github: String? = null, + onDone: (MetadataEvent) -> Unit, + ) { + val latest = getUserMetadataEvent() + + val template = + if (latest != null) { + MetadataEvent.updateFromPast( + latest = latest, + name = name, + displayName = name, + picture = picture, + banner = banner, + website = website, + pronouns = pronouns, + about = about, + nip05 = nip05, + lnAddress = lnAddress, + lnURL = lnURL, + twitter = twitter, + mastodon = mastodon, + github = github, + ) + } else { + MetadataEvent.createNew( + name = name, + displayName = name, + picture = picture, + banner = banner, + website = website, + pronouns = pronouns, + about = about, + nip05 = nip05, + lnAddress = lnAddress, + lnURL = lnURL, + twitter = twitter, + mastodon = mastodon, + github = github, + ) + } + + signer.sign(template, onDone) + } + + init { + settings.backupUserMetadata?.let { + Log.d("AccountRegisterObservers", "Loading saved user metadata ${it.toJson()}") + + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } + } + + // saves contact list for the next time. + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Kind 0 Collector Start") + getUserMetadataFlow().collect { + Log.d("AccountRegisterObservers", "Updating Kind 0 ${it.user.toBestDisplayName()}") + settings.updateUserMetadata(it.user.latestMetadata) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxRelays.kt new file mode 100644 index 000000000..ad63c5daf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxRelays.kt @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2025 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.model.nip02FollowLists + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlin.collections.map +import kotlin.collections.toSet + +class FollowListOutboxRelays( + kind3Follows: FollowListState, + val cache: LocalCache, + scope: CoroutineScope, +) { + fun getNIP65RelayListAddress(pubkey: HexKey) = AdvertisedRelayListEvent.createAddress(pubkey) + + fun getNIP65RelayListNote(pubkey: HexKey): AddressableNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress(pubkey)) + + fun getNIP65RelayListFlow(pubkey: HexKey): StateFlow = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow + + fun getNIP65RelayList(pubkey: HexKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent + + fun allRelayListFlows(followList: Set): List> = followList.map { getNIP65RelayListFlow(it) } + + fun combineAllFlows(flows: List>): Flow> = + combine(flows) { relayListNotes: Array -> + relayListNotes.mapNotNull { + (it.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm() + } + }.map { + it.flatten().toSet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + kind3Follows.flow.transformLatest { followList -> + val flows: List> = allRelayListFlows(followList.authors) + val relayListFlows = combineAllFlows(flows) + emitAll(relayListFlows) + }.onStart { + kind3Follows.flow.value.authors.mapNotNull { + getNIP65RelayList(it)?.writeRelaysNorm() + }.flatten().toSet() + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val flowSet: StateFlow> = + flow.map { relayList -> + relayList.map { it.url }.toSet() + }.onStart { + kind3Follows.flow.value.authors.mapNotNull { + getNIP65RelayList(it)?.writeRelaysNorm()?.map { it.url }?.toSet() + }.flatten().toSet() + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt new file mode 100644 index 000000000..536faa18f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt @@ -0,0 +1,171 @@ +/** + * Copyright (c) 2025 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.model.nip02FollowLists + +import android.util.Log +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.UserState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch + +class FollowListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + // fun getEphemeralChatListAddress() = cache.getOrCreateUser(signer.pubKey) + + fun getFollowListUser(): User = cache.getOrCreateUser(signer.pubKey) + + fun getFollowListFlow(): StateFlow = getFollowListUser().flow().follows.stateFlow + + fun getFollowListEvent(): ContactListEvent? = getFollowListUser().latestContactList + + @OptIn(ExperimentalCoroutinesApi::class) + private val innerFlow: Flow = + getFollowListFlow().transformLatest { + emit(buildKind3Follows(it.user.latestContactList)) + } + + val flow = + innerFlow + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + buildKind3Follows(getFollowListEvent() ?: settings.backupContactList), + ) + + /** + This contains a big OR of everything the user wants to see in the a single feed. + */ + @Immutable + class Kind3Follows( + val authors: Set = emptySet(), + val authorsPlusMe: Set, + val hashtags: Set = emptySet(), + val geotags: Set = emptySet(), + val communities: Set = emptySet(), + ) { + val geotagScopes: Set = geotags.mapTo(mutableSetOf()) { GeohashId.toScope(it) } + val hashtagScopes: Set = hashtags.mapTo(mutableSetOf()) { HashtagId.toScope(it) } + } + + fun buildKind3Follows(latestContactList: ContactListEvent?): Kind3Follows { + // makes sure the output include only valid p tags + val verifiedFollowingUsers = latestContactList?.verifiedFollowKeySet() ?: emptySet() + + return Kind3Follows( + authors = verifiedFollowingUsers, + authorsPlusMe = verifiedFollowingUsers + signer.pubKey, + hashtags = + latestContactList + ?.unverifiedFollowTagSet() + ?.map { it.lowercase() } + ?.toSet() ?: emptySet(), + geotags = + latestContactList + ?.geohashes() + ?.toSet() ?: emptySet(), + communities = + latestContactList + ?.verifiedFollowAddressSet() + ?.toSet() ?: emptySet(), + ) + } + + fun follow( + user: User, + onDone: (ContactListEvent) -> Unit, + ) { + if (!signer.isWriteable()) return + val contactList = getFollowListEvent() + + if (contactList != null) { + ContactListEvent.followUser(contactList, user.pubkeyHex, signer, onReady = onDone) + } else { + ContactListEvent.createFromScratch( + followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)), + followTags = emptyList(), + followGeohashes = emptyList(), + followCommunities = emptyList(), + relayUse = emptyMap(), + signer = signer, + onReady = onDone, + ) + } + } + + fun unfollow( + user: User, + onDone: (ContactListEvent) -> Unit, + ) { + if (!signer.isWriteable()) return + val contactList = getFollowListEvent() + + if (contactList != null && contactList.tags.isNotEmpty()) { + ContactListEvent.unfollowUser( + contactList, + user.pubkeyHex, + signer, + onReady = onDone, + ) + } + } + + init { + settings.backupContactList?.let { + Log.d("AccountRegisterObservers", "Loading saved contacts ${it.toJson()}") + + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } + } + + // saves contact list for the next time. + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Kind 3 Collector Start") + getFollowListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Kind 3 ${signer.pubKey}") + settings.updateContactListTo(it.user.latestContactList) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt new file mode 100644 index 000000000..89207b4ff --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2025 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.model.nip02FollowLists + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.mapOfSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlin.collections.map + +class FollowsPerOutboxRelay( + kind3Follows: FollowListState, + val cache: LocalCache, + scope: CoroutineScope, +) { + fun getNIP65RelayListAddress(pubkey: HexKey) = AdvertisedRelayListEvent.createAddress(pubkey) + + fun getNIP65RelayListNote(pubkey: HexKey): AddressableNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress(pubkey)) + + fun getNIP65RelayListFlow(pubkey: HexKey): StateFlow = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow + + fun getNIP65RelayList(pubkey: HexKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent + + fun allRelayListFlows(followList: Set): List> = followList.map { getNIP65RelayListFlow(it) } + + fun combineAllFlows(flows: List>): Flow>> = + combine(flows) { relayListNotes: Array -> + mapOfSet { + relayListNotes.forEach { noteState -> + noteState.note.author?.pubkeyHex?.let { authorHex -> + (noteState.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()?.forEach { relay -> + add(relay, authorHex) + } + } + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow>> = + kind3Follows.flow.transformLatest { followList -> + val flows: List> = allRelayListFlows(followList.authors) + val relayListFlows = combineAllFlows(flows) + emitAll(relayListFlows) + }.onStart { + emit( + mapOfSet { + kind3Follows.flow.value.authors.map { authorHex -> + getNIP65RelayList(authorHex)?.writeRelaysNorm()?.forEach { relay -> + add(relay, authorHex) + } + } + }, + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyMap(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmInboxRelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmInboxRelayState.kt new file mode 100644 index 000000000..d1e9def87 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmInboxRelayState.kt @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2025 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.model.nip17Dms + +import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState +import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState +import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn + +class DmInboxRelayState( + // main relay + dmRelayList: DmRelayListState, + // backup relays + nip65RelayList: Nip65RelayListState, + privateOutbox: PrivateStorageRelayListState, + localRelayList: LocalRelayListState, + scope: CoroutineScope, +) { + val flow = + combine( + nip65RelayList.inboxFlow, + dmRelayList.flow, + privateOutbox.flow, + localRelayList.flow, + ) { nip65Inbox, dmRelayList, privateOutBox, localRelays -> + nip65Inbox + dmRelayList + privateOutBox + localRelays + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + nip65RelayList.inboxFlow.value + + dmRelayList.flow.value + + privateOutbox.flow.value + + localRelayList.flow.value, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt new file mode 100644 index 000000000..711ea3a93 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2025 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.model.nip17Dms + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class DmRelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getDMRelayListAddress() = ChatMessageRelayListEvent.createAddress(signer.pubKey) + + fun getDMRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getDMRelayListAddress()) + + fun getDMRelayListFlow(): StateFlow = getDMRelayListNote().flow().metadata.stateFlow + + fun getDMRelayList(): ChatMessageRelayListEvent? = getDMRelayListNote().event as? ChatMessageRelayListEvent + + fun normalizeDMRelayListWithBackup(note: Note): Set { + val event = note.event as? ChatMessageRelayListEvent ?: settings.backupDMRelayList + return event?.relays()?.toSet() ?: emptySet() + } + + val flow = + getDMRelayListFlow() + .map { normalizeDMRelayListWithBackup(it.note) } + .onStart { emit(normalizeDMRelayListWithBackup(getDMRelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + fun saveRelayList( + dmRelays: List, + onDone: (ChatMessageRelayListEvent) -> Unit, + ) { + val relayListForDMs = getDMRelayList() + if (relayListForDMs != null && relayListForDMs.tags.isNotEmpty()) { + ChatMessageRelayListEvent.updateRelayList( + earlierVersion = relayListForDMs, + relays = dmRelays, + signer = signer, + onReady = onDone, + ) + } else { + ChatMessageRelayListEvent.createFromScratch( + relays = dmRelays, + signer = signer, + onReady = onDone, + ) + } + } + + init { + settings.backupDMRelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved DM Relay List ${it.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + LocalCache.justConsumeMyOwnEvent(it) + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "NIP-17 Relay List Collector Start") + getDMRelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating DM Relay List for ${signer.pubKey}") + (it.note.event as? ChatMessageRelayListEvent)?.let { + settings.updateDMRelayList(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip18Reposts/RepostAction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip18Reposts/RepostAction.kt new file mode 100644 index 000000000..54271181b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip18Reposts/RepostAction.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 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.model.nip18Reposts + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent + +class RepostAction { + companion object { + fun repost( + note: Note, + signer: NostrSigner, + onDone: (Event) -> Unit, + ) { + if (!signer.isWriteable()) return + val noteEvent = note.event ?: return + + if (note.hasBoostedInTheLast5Minutes(signer.pubKey)) { + // has already bosted in the past 5mins + return + } + + val noteHint = note.relayHintUrl() + val authorHint = note.author?.bestRelayHint() + + val template = + if (noteEvent.kind == 1) { + RepostEvent.build(noteEvent, noteHint, authorHint) + } else { + GenericRepostEvent.build(noteEvent, noteHint, authorHint) + } + + signer.sign(template, onDone) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip25Reactions/ReactionAction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip25Reactions/ReactionAction.kt new file mode 100644 index 000000000..9174c13d3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip25Reactions/ReactionAction.kt @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2025 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.model.nip25Reactions + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag + +class ReactionAction { + companion object { + suspend fun reactTo( + note: Note, + reaction: String, + by: User, + signer: NostrSigner, + onPublic: (ReactionEvent) -> Unit, + onPrivate: (NIP17Factory.Result) -> Unit, + ) { + if (!signer.isWriteable()) return + + if (note.hasReacted(by, reaction)) { + // has already liked this note + return + } + + val noteEvent = note.event + if (noteEvent is NIP17Group) { + val users = noteEvent.groupMembers().toList() + + if (reaction.startsWith(":")) { + val emojiUrl = EmojiUrlTag.decode(reaction) + if (emojiUrl != null) { + note.toEventHint()?.let { + NIP17Factory().createReactionWithinGroup( + emojiUrl = emojiUrl, + originalNote = it, + to = users, + signer = signer, + ) { + onPrivate(it) + } + } + + return + } + } + + note.toEventHint()?.let { + NIP17Factory().createReactionWithinGroup( + content = reaction, + originalNote = it, + to = users, + signer = signer, + ) { + onPrivate(it) + } + } + return + } else { + if (reaction.startsWith(":")) { + val emojiUrl = EmojiUrlTag.decode(reaction) + if (emojiUrl != null) { + note.event?.let { + val template = ReactionEvent.build(emojiUrl, EventHintBundle(it, note.relayHintUrl())) + + signer.sign( + template, + onReady = onPublic, + ) + } + + return + } + } + + note.toEventHint()?.let { + signer.sign( + ReactionEvent.build(reaction, it), + onReady = onPublic, + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListState.kt index 64368a690..5dd1f9cd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip28PublicChats/PublicChatListState.kt @@ -20,18 +20,23 @@ */ package com.vitorpamplona.amethyst.model.nip28PublicChats +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.tryAndWait import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.utils.tryAndWait import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOn @@ -39,12 +44,14 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch import kotlin.coroutines.resume class PublicChatListState( val signer: NostrSigner, val cache: LocalCache, val scope: CoroutineScope, + val settings: AccountSettings, ) { fun getChannelListAddress() = ChannelListEvent.createAddress(signer.pubKey) @@ -54,31 +61,27 @@ class PublicChatListState( fun getChannelList(): ChannelListEvent? = getChannelListNote().event as? ChannelListEvent + suspend fun publicChatListWithBackup(note: Note): Set { + return publicChatList( + note.event as? ChannelListEvent ?: settings.backupChannelList, + ) + } + + suspend fun publicChatList(event: ChannelListEvent?): Set { + return tryAndWait { continuation -> + event?.publicAndPrivateChannels(signer) { + continuation.resume(it) + } + } ?: emptySet() + } + @OptIn(ExperimentalCoroutinesApi::class) - val livePublicChatList: StateFlow> by lazy { + val flow: StateFlow> by lazy { getChannelListFlow() .transformLatest { noteState -> - val set = - tryAndWait { continuation -> - (noteState.note.event as? ChannelListEvent)?.publicAndPrivateChannels(signer) { - continuation.resume(it) - } - } - - if (set != null) { - emit(set) - } + emit(publicChatListWithBackup(noteState.note)) }.onStart { - val set = - tryAndWait { continuation -> - getChannelList()?.publicAndPrivateChannels(signer) { - continuation.resume(it) - } - } - - if (set != null) { - emit(set) - } + emit(publicChatListWithBackup(getChannelListNote())) }.flowOn(Dispatchers.Default) .stateIn( scope, @@ -88,8 +91,8 @@ class PublicChatListState( } @OptIn(ExperimentalCoroutinesApi::class) - val livePublicChatEventIdSet: StateFlow> by lazy { - livePublicChatList + val flowSet: StateFlow> by lazy { + flow .map { it.mapTo(mutableSetOf()) { it.eventId } }.flowOn(Dispatchers.Default) @@ -104,6 +107,7 @@ class PublicChatListState( channel: PublicChatChannel, onDone: (ChannelListEvent) -> Unit, ) { + if (!signer.isWriteable()) return val publicChatList = getChannelList() val fullHint = channel.toEventHint() @@ -127,6 +131,7 @@ class PublicChatListState( channels: List, onDone: (ChannelListEvent) -> Unit, ) { + if (!signer.isWriteable()) return val publicChatList = getChannelList() val partialHint = channels.map { it.toEventId() } @@ -141,10 +146,33 @@ class PublicChatListState( channel: PublicChatChannel, onDone: (ChannelListEvent) -> Unit, ) { + if (!signer.isWriteable()) return val publicChatList = getChannelList() if (publicChatList != null) { ChannelListEvent.removeChannel(publicChatList, channel.idHex, signer, onReady = onDone) } } + + init { + settings.backupChannelList?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved channel list ${event.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + event.privateTags(signer) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Channel List Collector Start") + getChannelListFlow().collect { + Log.d("AccountRegisterObservers", "Channel List for ${signer.pubKey}") + (it.note.event as? ChannelListEvent)?.let { + settings.updateChannelListTo(it) + } + } + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/EmojiPackState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/EmojiPackState.kt index 4c9858012..9d43b89ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/EmojiPackState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/EmojiPackState.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip30CustomEmojis import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses @@ -67,7 +68,7 @@ class EmojiPackState( } @OptIn(ExperimentalCoroutinesApi::class) - val liveEmojiSelectionPack: StateFlow>?> by lazy { + val flow: StateFlow>?> by lazy { getEmojiPackSelectionFlow() .transformLatest { emit(convertEmojiSelectionPack(it.note.event as? EmojiPackSelectionEvent)) @@ -98,7 +99,7 @@ class EmojiPackState( @OptIn(ExperimentalCoroutinesApi::class) val myEmojis by lazy { - liveEmojiSelectionPack + flow .transformLatest { emojiList -> if (emojiList != null) { emitAll( @@ -116,4 +117,40 @@ class EmojiPackState( mergePack(convertEmojiSelectionPack(getEmojiPackSelection())?.map { it.value }?.toTypedArray() ?: emptyArray()), ) } + + fun addEmojiPack( + emojiPack: Note, + onDone: (EmojiPackSelectionEvent) -> Unit, + ) { + if (!signer.isWriteable()) return + + val emojiPackEvent = emojiPack.event + if (emojiPackEvent !is EmojiPackEvent) return + + val eventHint = emojiPack.toEventHint() ?: return + + val usersEmojiList = getEmojiPackSelection() + if (usersEmojiList == null) { + val template = EmojiPackSelectionEvent.build(listOf(eventHint)) + signer.sign(template, onDone) + } else { + val template = EmojiPackSelectionEvent.add(usersEmojiList, eventHint) + signer.sign(template, onDone) + } + } + + fun removeEmojiPack( + emojiPack: Note, + onDone: (EmojiPackSelectionEvent) -> Unit, + ) { + if (!signer.isWriteable()) return + + val usersEmojiList = getEmojiPackSelection() ?: return + + val emojiPackEvent = emojiPack.event + if (emojiPackEvent !is EmojiPackEvent) return + + val template = EmojiPackSelectionEvent.remove(usersEmojiList, emojiPackEvent) + signer.sign(template, onDone) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip38UserStatuses/UserStatusAction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip38UserStatuses/UserStatusAction.kt new file mode 100644 index 000000000..645d8a2c8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip38UserStatuses/UserStatusAction.kt @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2025 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.model.nip38UserStatuses + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent + +class UserStatusAction { + companion object { + fun create( + newStatus: String, + signer: NostrSigner, + onDone: (StatusEvent) -> Unit, + ) { + if (!signer.isWriteable()) return + + StatusEvent.create(newStatus, "general", expiration = null, signer, onReady = onDone) + } + + fun update( + oldStatus: AddressableNote, + newStatus: String, + signer: NostrSigner, + onDone: (StatusEvent) -> Unit, + ) { + if (!signer.isWriteable()) return + val oldEvent = oldStatus.event as? StatusEvent ?: return + + StatusEvent.update(oldEvent, newStatus, signer, onReady = onDone) + } + + fun delete( + oldStatus: AddressableNote, + signer: NostrSigner, + onDone: (Event) -> Unit, + ) { + if (!signer.isWriteable()) return + val oldEvent = oldStatus.event as? StatusEvent ?: return + + StatusEvent.clear(oldEvent, signer) { event -> + onDone(event) + + signer.sign( + DeletionEvent.buildForVersionOnly(listOf(event)), + onReady = onDone, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip50Search/SearchRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip50Search/SearchRelayListState.kt new file mode 100644 index 000000000..5f4a75106 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip50Search/SearchRelayListState.kt @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2025 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.model.nip50Search + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.DefaultSearchRelayList +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class SearchRelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getSearchRelayListAddress() = SearchRelayListEvent.createAddress(signer.pubKey) + + fun getSearchRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getSearchRelayListAddress()) + + fun getSearchRelayListFlow(): StateFlow = getSearchRelayListNote().flow().metadata.stateFlow + + fun getSearchRelayList(): SearchRelayListEvent? = getSearchRelayListNote().event as? SearchRelayListEvent + + fun normalizeSearchRelayListWithBackup(note: Note): Set { + val event = note.event as? SearchRelayListEvent ?: settings.backupSearchRelayList + return event?.relays()?.toSet() ?: DefaultSearchRelayList + } + + val flow = + getSearchRelayListFlow() + .map { normalizeSearchRelayListWithBackup(it.note) } + .onStart { emit(normalizeSearchRelayListWithBackup(getSearchRelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + fun saveRelayList( + searchRelays: List, + onDone: (SearchRelayListEvent) -> Unit, + ) { + val relayListForSearch = getSearchRelayList() + + if (relayListForSearch != null && relayListForSearch.tags.isNotEmpty()) { + SearchRelayListEvent.updateRelayList( + earlierVersion = relayListForSearch, + relays = searchRelays, + signer = signer, + onReady = onDone, + ) + } else { + SearchRelayListEvent.createFromScratch( + relays = searchRelays, + signer = signer, + onReady = onDone, + ) + } + } + + init { + settings.backupSearchRelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved search relay list ${it.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Search Relay List Collector Start") + getSearchRelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Search Relay List for ${signer.pubKey}") + (it.note.event as? SearchRelayListEvent)?.let { + settings.updateSearchRelayList(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockPeopleListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockPeopleListState.kt new file mode 100644 index 000000000..148cec26b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockPeopleListState.kt @@ -0,0 +1,158 @@ +/** + * Copyright (c) 2025 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.model.nip51Lists + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.utils.tryAndWait +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlin.coroutines.resume + +class BlockPeopleListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, +) { + fun getBlockListAddress() = PeopleListEvent.createBlockAddress(signer.pubKey) + + fun getBlockListNote() = LocalCache.getOrCreateAddressableNote(getBlockListAddress()) + + fun getBlockListFlow(): StateFlow = getBlockListNote().flow().metadata.stateFlow + + fun getBlockList(): PeopleListEvent? = getBlockListNote().event as? PeopleListEvent + + suspend fun blockListWithBackup(note: Note): PeopleListEvent.UsersAndWords { + return blockList( + note.event as? PeopleListEvent, + ) + } + + suspend fun blockList(event: PeopleListEvent?): PeopleListEvent.UsersAndWords { + return tryAndWait { continuation -> + event?.publicAndPrivateUsersAndWords(signer) { + continuation.resume(it) + } + } ?: PeopleListEvent.UsersAndWords() + } + + val flow = + getBlockListFlow() + .map { blockListWithBackup(it.note) } + .onStart { emit(blockListWithBackup(getBlockListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + PeopleListEvent.UsersAndWords(), + ) + + fun hideUser( + pubkeyHex: String, + onDone: (PeopleListEvent) -> Unit, + ) { + val blockList = getBlockList() + + if (blockList != null) { + PeopleListEvent.addUser( + earlierVersion = blockList, + pubKeyHex = pubkeyHex, + isPrivate = true, + signer = signer, + onReady = onDone, + ) + } else { + PeopleListEvent.createListWithUser( + name = PeopleListEvent.BLOCK_LIST_D_TAG, + pubKeyHex = pubkeyHex, + isPrivate = true, + signer = signer, + onReady = onDone, + ) + } + } + + fun showUser( + pubkeyHex: String, + onDone: (PeopleListEvent) -> Unit, + ) { + val blockList = getBlockList() + + if (blockList != null) { + PeopleListEvent.removeUser( + earlierVersion = blockList, + pubKeyHex = pubkeyHex, + signer = signer, + onReady = onDone, + ) + } + } + + fun hideWord( + word: String, + onDone: (PeopleListEvent) -> Unit, + ) { + val blockList = getBlockList() + + if (blockList != null) { + PeopleListEvent.addWord( + earlierVersion = blockList, + word = word, + isPrivate = true, + signer = signer, + onReady = onDone, + ) + } else { + PeopleListEvent.createListWithWord( + name = PeopleListEvent.BLOCK_LIST_D_TAG, + word = word, + isPrivate = true, + signer = signer, + onReady = onDone, + ) + } + } + + fun showWord( + word: String, + onDone: (PeopleListEvent) -> Unit, + ) { + val blockList = getBlockList() + + if (blockList != null) { + PeopleListEvent.removeWord( + earlierVersion = blockList, + word = word, + signer = signer, + onReady = onDone, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/GeohashListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/GeohashListState.kt new file mode 100644 index 000000000..3464486e4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/GeohashListState.kt @@ -0,0 +1,150 @@ +/** + * Copyright (c) 2025 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.model.nip51Lists + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent +import com.vitorpamplona.quartz.utils.tryAndWait +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch +import kotlin.coroutines.resume + +class GeohashListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getGeohashListAddress() = GeohashListEvent.createAddress(signer.pubKey) + + fun getGeohashListNote(): AddressableNote = cache.getOrCreateAddressableNote(getGeohashListAddress()) + + fun getGeohashListFlow(): StateFlow = getGeohashListNote().flow().metadata.stateFlow + + fun getGeohashList(): GeohashListEvent? = getGeohashListNote().event as? GeohashListEvent + + suspend fun geohashListWithBackup(note: Note): Set { + return geohashList( + note.event as? GeohashListEvent ?: settings.backupGeohashList, + ) + } + + suspend fun geohashList(event: GeohashListEvent?): Set { + return tryAndWait { continuation -> + event?.publicAndPrivateGeohash(signer) { + continuation.resume(it) + } + } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> by lazy { + getGeohashListFlow() + .transformLatest { noteState -> + emit(geohashListWithBackup(noteState.note)) + }.onStart { + emit(geohashListWithBackup(getGeohashListNote())) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + } + + fun follow( + geohashs: List, + onDone: (GeohashListEvent) -> Unit, + ) { + if (!signer.isWriteable()) return + val geohashList = getGeohashList() + + if (geohashList == null) { + GeohashListEvent.createGeohashs(geohashs, true, signer, onReady = onDone) + } else { + GeohashListEvent.addGeohashs(geohashList, geohashs, true, signer, onReady = onDone) + } + } + + fun follow( + geohash: String, + onDone: (GeohashListEvent) -> Unit, + ) { + if (!signer.isWriteable()) return + val geohashList = getGeohashList() + + if (geohashList == null) { + GeohashListEvent.createGeohash(geohash, true, signer, onReady = onDone) + } else { + GeohashListEvent.addGeohash(geohashList, geohash, true, signer, onReady = onDone) + } + } + + fun unfollow( + geohash: String, + onDone: (GeohashListEvent) -> Unit, + ) { + if (!signer.isWriteable()) return + val geohashList = getGeohashList() + + if (geohashList != null) { + GeohashListEvent.removeGeohash(geohashList, geohash, signer, onReady = onDone) + } + } + + init { + settings.backupGeohashList?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved Geohash list ${event.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + event.privateTags(signer) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Geohash List Collector Start") + getGeohashListFlow().collect { + Log.d("AccountRegisterObservers", "Geohash List for ${signer.pubKey}") + (it.note.event as? GeohashListEvent)?.let { + settings.updateGeohashListTo(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HashtagListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HashtagListState.kt new file mode 100644 index 000000000..65d18040e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HashtagListState.kt @@ -0,0 +1,150 @@ +/** + * Copyright (c) 2025 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.model.nip51Lists + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent +import com.vitorpamplona.quartz.utils.tryAndWait +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch +import kotlin.coroutines.resume + +class HashtagListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getHashtagListAddress() = HashtagListEvent.createAddress(signer.pubKey) + + fun getHashtagListNote(): AddressableNote = cache.getOrCreateAddressableNote(getHashtagListAddress()) + + fun getHashtagListFlow(): StateFlow = getHashtagListNote().flow().metadata.stateFlow + + fun getHashtagList(): HashtagListEvent? = getHashtagListNote().event as? HashtagListEvent + + suspend fun hashtagListWithBackup(note: Note): Set { + return hashtagList( + note.event as? HashtagListEvent ?: settings.backupHashtagList, + ) + } + + suspend fun hashtagList(event: HashtagListEvent?): Set { + return tryAndWait { continuation -> + event?.publicAndPrivateHashtag(signer) { + continuation.resume(it) + } + } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> by lazy { + getHashtagListFlow() + .transformLatest { noteState -> + emit(hashtagListWithBackup(noteState.note)) + }.onStart { + emit(hashtagListWithBackup(getHashtagListNote())) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + } + + fun follow( + hashtags: List, + onDone: (HashtagListEvent) -> Unit, + ) { + if (!signer.isWriteable()) return + val hashtagList = getHashtagList() + + if (hashtagList == null) { + HashtagListEvent.createHashtags(hashtags, true, signer, onReady = onDone) + } else { + HashtagListEvent.addHashtags(hashtagList, hashtags, true, signer, onReady = onDone) + } + } + + fun follow( + hashtag: String, + onDone: (HashtagListEvent) -> Unit, + ) { + if (!signer.isWriteable()) return + val hashtagList = getHashtagList() + + if (hashtagList == null) { + HashtagListEvent.createHashtag(hashtag, true, signer, onReady = onDone) + } else { + HashtagListEvent.addHashtag(hashtagList, hashtag, true, signer, onReady = onDone) + } + } + + fun unfollow( + hashtag: String, + onDone: (HashtagListEvent) -> Unit, + ) { + if (!signer.isWriteable()) return + val hashtagList = getHashtagList() + + if (hashtagList != null) { + HashtagListEvent.removeHashtag(hashtagList, hashtag, signer, onReady = onDone) + } + } + + init { + settings.backupHashtagList?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved Hashtag list ${event.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + event.privateTags(signer) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Hashtag List Collector Start") + getHashtagListFlow().collect { + Log.d("AccountRegisterObservers", "Hashtag List for ${signer.pubKey}") + (it.note.event as? HashtagListEvent)?.let { + settings.updateHashtagListTo(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt new file mode 100644 index 000000000..8a953176f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2025 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.model.nip51Lists + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.utils.DualCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking + +class HiddenUsersState( + val muteList: StateFlow, + val blockList: StateFlow, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + var transientHiddenUsers: MutableStateFlow> = MutableStateFlow(setOf()) + + @Immutable + class LiveHiddenUsers( + val hiddenUsers: Set, + val spammers: Set, + val hiddenWords: Set, + val showSensitiveContent: Boolean?, + ) { + // speeds up isHidden calculations + val hiddenUsersHashCodes = hiddenUsers.mapTo(HashSet()) { it.hashCode() } + val spammersHashCodes = spammers.mapTo(HashSet()) { it.hashCode() } + val hiddenWordsCase = hiddenWords.map { DualCase(it.lowercase(), it.uppercase()) } + + fun isUserHidden(userHex: HexKey) = hiddenUsers.contains(userHex) || spammers.contains(userHex) + } + + suspend fun assembleLiveHiddenUsers( + blockList: PeopleListEvent.UsersAndWords, + muteList: PeopleListEvent.UsersAndWords, + transientHiddenUsers: Set, + showSensitiveContent: Boolean?, + ): LiveHiddenUsers { + return LiveHiddenUsers( + hiddenUsers = blockList.users + muteList.users, + hiddenWords = blockList.words + muteList.words, + spammers = transientHiddenUsers, + showSensitiveContent = showSensitiveContent, + ) + } + + val flow: StateFlow by lazy { + combineTransform( + blockList, + muteList, + transientHiddenUsers, + settings.syncedSettings.security.showSensitiveContent, + ) { blockList, muteList, transientHiddenUsers, showSensitiveContent -> + checkNotInMainThread() + emit(assembleLiveHiddenUsers(blockList, muteList, transientHiddenUsers, showSensitiveContent)) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + runBlocking { + assembleLiveHiddenUsers( + blockList.value, + muteList.value, + transientHiddenUsers.value, + settings.syncedSettings.security.showSensitiveContent.value, + ) + }, + ) + } + + fun resetTransientUsers() { + transientHiddenUsers.update { + emptySet() + } + } + + fun showUser(pubkeyHex: HexKey) { + transientHiddenUsers.update { it - pubkeyHex } + } + + fun hideUser(pubkeyHex: HexKey) { + transientHiddenUsers.update { it + pubkeyHex } + } + + fun isHidden(pubkeyHex: HexKey) = pubkeyHex in transientHiddenUsers.value +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/MuteListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/MuteListState.kt new file mode 100644 index 000000000..42e68cda7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/MuteListState.kt @@ -0,0 +1,185 @@ +/** + * Copyright (c) 2025 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.model.nip51Lists + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.utils.tryAndWait +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlin.coroutines.resume + +class MuteListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getMuteListAddress() = MuteListEvent.createAddress(signer.pubKey) + + fun getMuteListNote() = cache.getOrCreateAddressableNote(getMuteListAddress()) + + fun getMuteListFlow(): StateFlow = getMuteListNote().flow().metadata.stateFlow + + fun getMuteList(): MuteListEvent? = getMuteListNote().event as? MuteListEvent + + suspend fun muteListWithBackup(note: Note): PeopleListEvent.UsersAndWords { + return muteList( + note.event as? MuteListEvent ?: settings.backupMuteList, + ) + } + + suspend fun muteList(event: MuteListEvent?): PeopleListEvent.UsersAndWords { + return tryAndWait { continuation -> + event?.publicAndPrivateUsersAndWords(signer) { + continuation.resume(it) + } + } ?: PeopleListEvent.UsersAndWords() + } + + val flow = + getMuteListFlow() + .map { muteListWithBackup(it.note) } + .onStart { emit(muteListWithBackup(getMuteListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + PeopleListEvent.UsersAndWords(), + ) + + fun hideUser( + pubkeyHex: String, + onDone: (MuteListEvent) -> Unit, + ) { + val muteList = getMuteList() + + if (muteList != null) { + MuteListEvent.addUser( + earlierVersion = muteList, + pubKeyHex = pubkeyHex, + isPrivate = true, + signer = signer, + onReady = onDone, + ) + } else { + MuteListEvent.createListWithUser( + pubKeyHex = pubkeyHex, + isPrivate = true, + signer = signer, + onReady = onDone, + ) + } + } + + fun showUser( + pubkeyHex: String, + onDone: (MuteListEvent) -> Unit, + ) { + val muteList = getMuteList() + + if (muteList != null) { + MuteListEvent.removeUser( + earlierVersion = muteList, + pubKeyHex = pubkeyHex, + signer = signer, + onReady = onDone, + ) + } + } + + fun hideWord( + word: String, + onDone: (MuteListEvent) -> Unit, + ) { + val muteList = getMuteList() + + if (muteList != null) { + MuteListEvent.addWord( + earlierVersion = muteList, + word = word, + isPrivate = true, + signer = signer, + onReady = onDone, + ) + } else { + MuteListEvent.createListWithWord( + word = word, + isPrivate = true, + signer = signer, + onReady = onDone, + ) + } + } + + fun showWord( + word: String, + onDone: (MuteListEvent) -> Unit, + ) { + val muteList = getMuteList() + + if (muteList != null) { + MuteListEvent.removeWord( + earlierVersion = muteList, + word = word, + signer = signer, + onReady = onDone, + ) + } + } + + init { + settings.backupMuteList?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved mute list ${event.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + event.privateTags(signer) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Mute List Collector Start") + getMuteListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Mute List for ${signer.pubKey}") + (it.note.event as? MuteListEvent)?.let { + settings.updateMuteList(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip56Reports/ReportAction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip56Reports/ReportAction.kt new file mode 100644 index 000000000..bdec0ec8c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip56Reports/ReportAction.kt @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2025 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.model.nip56Reports + +import android.R.attr.type +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip56Reports.ReportType + +class ReportAction { + companion object { + fun report( + user: User, + type: ReportType, + by: User, + signer: NostrSigner, + onDone: (ReportEvent) -> Unit, + ) { + if (!signer.isWriteable()) return + + if (user.hasReport(by, type)) { + // has already reported this note + return + } + + val template = ReportEvent.build(user.pubkeyHex, type) + + signer.sign(template, onDone) + } + + suspend fun report( + note: Note, + type: ReportType, + content: String = "", + by: User, + signer: NostrSigner, + onDone: (ReportEvent) -> Unit, + ) { + if (!signer.isWriteable()) return + + if (note.hasReport(by, type)) { + // has already reported this note + return + } + + note.event?.let { + signer.sign(ReportEvent.build(it, type), onDone) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt new file mode 100644 index 000000000..1018b03ae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt @@ -0,0 +1,149 @@ +/** + * Copyright (c) 2025 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.model.nip65RelayList + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class Nip65RelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getNIP65RelayListAddress() = AdvertisedRelayListEvent.createAddress(signer.pubKey) + + fun getNIP65RelayListNote(): AddressableNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress()) + + fun getNIP65RelayListFlow(): StateFlow = getNIP65RelayListNote().flow().metadata.stateFlow + + fun getNIP65RelayList(): AdvertisedRelayListEvent? = getNIP65RelayListNote().event as? AdvertisedRelayListEvent + + fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set { + val event = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList + return event?.writeRelaysNorm()?.toSet() ?: Constants.eventFinderRelays + } + + fun normalizeNIP65ReadRelayListWithBackup(note: Note): Set { + val event = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList + return event?.readRelaysNorm()?.toSet() ?: Constants.eventFinderRelays + } + + fun normalizeNIP65AllRelayListWithBackup(note: Note): Set { + val event = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList + return event?.relays()?.map { it.relayUrl }?.toSet() ?: Constants.eventFinderRelays + } + + val outboxFlow = + getNIP65RelayListFlow() + .map { normalizeNIP65WriteRelayListWithBackup(it.note) } + .onStart { emit(normalizeNIP65ReadRelayListWithBackup(getNIP65RelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val inboxFlow = + getNIP65RelayListFlow() + .map { normalizeNIP65ReadRelayListWithBackup(it.note) } + .onStart { emit(normalizeNIP65ReadRelayListWithBackup(getNIP65RelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val allFlow = + getNIP65RelayListFlow() + .map { normalizeNIP65AllRelayListWithBackup(it.note) } + .onStart { emit(normalizeNIP65AllRelayListWithBackup(getNIP65RelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + fun saveRelayList( + relays: List, + onDone: (AdvertisedRelayListEvent) -> Unit, + ) { + if (!signer.isWriteable()) return + + val nip65RelayList = getNIP65RelayList() + + if (nip65RelayList != null) { + AdvertisedRelayListEvent.replaceRelayListWith( + earlierVersion = nip65RelayList, + newRelays = relays, + signer = signer, + onReady = onDone, + ) + } else { + AdvertisedRelayListEvent.createFromScratch( + relays = relays, + signer = signer, + onReady = onDone, + ) + } + } + + init { + settings.backupNIP65RelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved nip65 relay list ${it.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "NIP-65 Relay List Collector Start") + getNIP65RelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating NIP-65 List for ${signer.pubKey}") + (it.note.event as? AdvertisedRelayListEvent)?.let { + settings.updateNIP65RelayList(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/OutboxRelaySetState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/OutboxRelaySetState.kt new file mode 100644 index 000000000..6467b4c7b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/OutboxRelaySetState.kt @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2025 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.model.nip65RelayList + +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +class OutboxRelaySetState( + usersToLoad: MutableStateFlow>, + val cache: LocalCache, + scope: CoroutineScope, +) { + fun getNIP65RelayListAddress(pubkey: HexKey) = AdvertisedRelayListEvent.Companion.createAddress(pubkey) + + fun getNIP65RelayListNote(pubkey: HexKey): AddressableNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress(pubkey)) + + fun getNIP65RelayListFlow(pubkey: HexKey): StateFlow = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow + + fun getNIP65RelayList(pubkey: HexKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent + + fun allRelayListFlows(followList: Set): List> = followList.map { getNIP65RelayListFlow(it) } + + fun combineAllFlows(flows: List>): Flow> = + combine(flows) { relayListNotes: Array -> + relayListNotes.mapNotNull { + (it.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm() + } + }.map { + it.flatten().toSet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + usersToLoad.transformLatest { followList -> + val flows: List> = allRelayListFlows(followList) + val relayListFlows = combineAllFlows(flows) + emitAll(relayListFlows) + }.onStart { + emit( + usersToLoad.value.mapNotNull { + getNIP65RelayList(it)?.writeRelaysNorm() + }.flatten().toSet(), + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Companion.Eagerly, + emptySet(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val flowSet: StateFlow> = + flow.map { relayList -> + relayList.map { it.url }.toSet() + }.onStart { + emit( + usersToLoad.value.mapNotNull { + getNIP65RelayList(it)?.writeRelaysNorm()?.map { it.url }?.toSet() + }.flatten().toSet(), + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Companion.Eagerly, + emptySet(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt new file mode 100644 index 000000000..375c88034 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt @@ -0,0 +1,177 @@ +/** + * Copyright (c) 2025 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.model.nip72Communities + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent +import com.vitorpamplona.quartz.utils.tryAndWait +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch +import kotlin.coroutines.resume + +class CommunityListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getCommunityListAddress() = CommunityListEvent.createAddress(signer.pubKey) + + fun getCommunityListNote(): AddressableNote = cache.getOrCreateAddressableNote(getCommunityListAddress()) + + fun getCommunityListFlow(): StateFlow = getCommunityListNote().flow().metadata.stateFlow + + fun getCommunityList(): CommunityListEvent? = getCommunityListNote().event as? CommunityListEvent + + suspend fun communityListWithBackup(note: Note): Set { + return communityList( + note.event as? CommunityListEvent ?: settings.backupCommunityList, + ) + } + + suspend fun communityList(event: CommunityListEvent?): Set { + return tryAndWait { continuation -> + event?.publicAndPrivateCommunities(signer) { + continuation.resume(it) + } + } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> by lazy { + getCommunityListFlow() + .transformLatest { noteState -> + emit(communityListWithBackup(noteState.note)) + }.onStart { + emit(communityListWithBackup(getCommunityListNote())) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flowSet: StateFlow> by lazy { + flow + .map { + it.mapTo(mutableSetOf()) { it.addressId } + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + } + + fun follow( + communities: List, + onDone: (CommunityListEvent) -> Unit, + ) { + if (!signer.isWriteable()) return + val communityList = getCommunityList() + + val partialHint = communities.mapNotNull { it.toEventHint() } + if (communityList == null) { + CommunityListEvent.createCommunities(partialHint, true, signer, onReady = onDone) + } else { + CommunityListEvent.addCommunities(communityList, partialHint, true, signer, onReady = onDone) + } + } + + fun follow( + community: AddressableNote, + onDone: (CommunityListEvent) -> Unit, + ) { + if (!signer.isWriteable()) return + val communityList = getCommunityList() + + val fullHint = community.toEventHint() + if (fullHint != null) { + if (communityList == null) { + CommunityListEvent.createCommunity(fullHint, true, signer, onReady = onDone) + } else { + CommunityListEvent.addCommunity(communityList, fullHint, true, signer, onReady = onDone) + } + } else { + val partialHint = community.toATag() + if (communityList == null) { + CommunityListEvent.createCommunity(partialHint, true, signer, onReady = onDone) + } else { + CommunityListEvent.addCommunity(communityList, partialHint, true, signer, onReady = onDone) + } + } + } + + fun unfollow( + community: AddressableNote, + onDone: (CommunityListEvent) -> Unit, + ) { + if (!signer.isWriteable()) return + val communityList = getCommunityList() + + if (communityList != null) { + CommunityListEvent.removeCommunity(communityList, community.address.toValue(), signer, onReady = onDone) + } + } + + init { + settings.backupCommunityList?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved Community list ${event.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + event.privateTags(signer) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Community List Collector Start") + getCommunityListFlow().collect { + Log.d("AccountRegisterObservers", "Community List for ${signer.pubKey}") + (it.note.event as? CommunityListEvent)?.let { + settings.updateCommunityListTo(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt new file mode 100644 index 000000000..da6285e74 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2025 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.model.nip78AppSpecific + +import android.util.Log +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AccountSyncedSettingsInternal +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +class AppSpecificState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + companion object { + const val APP_SPECIFIC_DATA_D_TAG = "AmethystSettings" + } + + fun getAppSpecificDataAddress() = AppSpecificDataEvent.createAddress(signer.pubKey, APP_SPECIFIC_DATA_D_TAG) + + fun getAppSpecificDataNote() = cache.getOrCreateAddressableNote(getAppSpecificDataAddress()) + + fun getAppSpecificDataFlow(): StateFlow = getAppSpecificDataNote().flow().metadata.stateFlow + + fun saveNewAppSpecificData(onDone: (AppSpecificDataEvent) -> Unit) { + val toInternal = settings.syncedSettings.toInternal() + signer.nip44Encrypt(EventMapper.mapper.writeValueAsString(toInternal), signer.pubKey) { encrypted -> + AppSpecificDataEvent.create( + dTag = APP_SPECIFIC_DATA_D_TAG, + description = encrypted, + otherTags = emptyArray(), + signer = signer, + onReady = onDone, + ) + } + } + + init { + settings.backupAppSpecificData?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved app specific data ${event.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + LocalCache.justConsumeMyOwnEvent(event) + signer.decrypt(event.content, event.pubKey) { decrypted -> + try { + val syncedSettings = EventMapper.mapper.readValue(decrypted) + settings.syncedSettings.updateFrom(syncedSettings) + } catch (e: Throwable) { + if (e is CancellationException) throw e + Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value $decrypted", e) + e.printStackTrace() + AccountSyncedSettingsInternal() + } + } + } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "AppSpecificData Collector Start") + getAppSpecificDataFlow().collect { + Log.d("AccountRegisterObservers", "Updating AppSpecificData for ${signer.pubKey}") + (it.note.event as? AppSpecificDataEvent)?.let { + signer.decrypt(it.content, it.pubKey) { decrypted -> + val syncedSettings = + try { + EventMapper.mapper.readValue(decrypted) + } catch (e: Throwable) { + if (e is CancellationException) throw e + Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences with value $decrypted", e) + e.printStackTrace() + AccountSyncedSettingsInternal() + } + + settings.updateAppSpecificData(it, syncedSettings) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip96FileStorage/FileStorageServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip96FileStorage/FileStorageServerListState.kt new file mode 100644 index 000000000..079e096bf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip96FileStorage/FileStorageServerListState.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2025 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.model.nip96FileStorage + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class FileStorageServerListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getFileServersAddress() = FileServersEvent.createAddress(signer.pubKey) + + fun getFileServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getFileServersAddress()) + + fun getFileServersListFlow(): StateFlow = getFileServersNote().flow().metadata.stateFlow + + fun getFileServersList(): FileServersEvent? = getFileServersNote().event as? FileServersEvent + + fun normalizeServers(note: Note): List { + val event = note.event as? FileServersEvent + return event?.servers() ?: emptyList() + } + + val fileServers = + getFileServersListFlow() + .map { normalizeServers(it.note) } + .onStart { emit(normalizeServers(getFileServersNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + fun saveFileServersList( + servers: List, + onDone: (FileServersEvent) -> Unit, + ) { + val serverList = getFileServersList() + + val template = + if (serverList != null && serverList.tags.isNotEmpty()) { + FileServersEvent.replaceServers(serverList, servers) + } else { + FileServersEvent.build(servers) + } + + signer.sign(template, onDone) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt new file mode 100644 index 000000000..8cf1196f1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2025 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.model.nipB7Blossom + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class BlossomServerListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getBlossomServersAddress() = BlossomServersEvent.createAddress(signer.pubKey) + + fun getBlossomServersNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getBlossomServersAddress()) + + fun getBlossomServersListFlow(): StateFlow = getBlossomServersNote().flow().metadata.stateFlow + + fun getBlossomServersList(): BlossomServersEvent? = getBlossomServersNote().event as? BlossomServersEvent + + fun normalizeServers(note: Note): List { + val event = note.event as? FileServersEvent + return event?.servers() ?: emptyList() + } + + val fileServers = + getBlossomServersListFlow() + .map { normalizeServers(it.note) } + .onStart { emit(normalizeServers(getBlossomServersNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + fun saveBlossomServersList( + servers: List, + onDone: (BlossomServersEvent) -> Unit, + ) { + val serverList = getBlossomServersList() + + if (serverList != null && serverList.tags.isNotEmpty()) { + BlossomServersEvent.updateRelayList( + earlierVersion = serverList, + relays = servers, + signer = signer, + onReady = onDone, + ) + } else { + BlossomServersEvent.createFromScratch( + relays = servers, + signer = signer, + onReady = onDone, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindAndAuthor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindAndAuthor.kt index 3b142b654..d7d383198 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindAndAuthor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindAndAuthor.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -51,10 +52,8 @@ class LatestByKindAndAuthor( if ((kind in 10000..19999) || (kind in 30000..39999)) { LocalCache.addressables .maxOrNullOf( - filter = { idHex: String, note: AddressableNote -> - note.event?.let { - it.kind == kind && it.pubKey == pubkey - } == true + filter = { address: Address, note: AddressableNote -> + address.kind == kind && address.pubKeyHex == pubkey }, comparator = CreatedAtComparatorAddresses, )?.event as? T diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt new file mode 100644 index 000000000..663b8adac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2025 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.model.serverList + +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState +import com.vitorpamplona.amethyst.model.nip51Lists.GeohashListState +import com.vitorpamplona.amethyst.model.nip51Lists.HashtagListState +import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class MergedFollowListsState( + val kind3List: FollowListState, + val hashtagList: HashtagListState, + val geohashList: GeohashListState, + val communityList: CommunityListState, + val scope: CoroutineScope, +) { + fun mergeLists( + kind3: FollowListState.Kind3Follows, + hashtages: Set, + geohashes: Set, + community: Set, + ): FollowListState.Kind3Follows { + return FollowListState.Kind3Follows( + kind3.authors, + kind3.authorsPlusMe, + kind3.hashtags + hashtages, + kind3.geotags + geohashes, + kind3.communities + community.map { it.addressId }, + ) + } + + val flow: StateFlow = + combine( + kind3List.flow, + hashtagList.flow, + geohashList.flow, + communityList.flow, + ) { kind3, hashtag, geohash, community -> + mergeLists(kind3, hashtag, geohash, community) + } + .onStart { + emit( + mergeLists( + kind3List.flow.value, + hashtagList.flow.value, + geohashList.flow.value, + communityList.flow.value, + ), + ) + } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + kind3List.flow.value, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineRelayListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineRelayListsState.kt new file mode 100644 index 000000000..714c7d23a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineRelayListsState.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2025 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.model.serverList + +import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState +import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListOutboxRelays +import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class MergedFollowPlusMineRelayListsState( + val followsOutboxRelayList: FollowListOutboxRelays, + val nip65RelayList: Nip65RelayListState, + val privateOutboxRelayList: PrivateStorageRelayListState, + val localRelayList: LocalRelayListState, + val scope: CoroutineScope, +) { + fun mergeLists( + kind3: Set, + outbox: Set, + inbox: Set, + private: Set, + local: Set, + ): Set { + return kind3 + outbox + inbox + private + local + } + + val flow: StateFlow> = + combine( + followsOutboxRelayList.flow, + nip65RelayList.outboxFlow, + nip65RelayList.inboxFlow, + privateOutboxRelayList.flow, + localRelayList.flow, + ::mergeLists, + ) + .onStart { + emit( + mergeLists( + followsOutboxRelayList.flow.value, + nip65RelayList.outboxFlow.value, + nip65RelayList.inboxFlow.value, + privateOutboxRelayList.flow.value, + localRelayList.flow.value, + ), + ) + } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + mergeLists( + followsOutboxRelayList.flow.value, + nip65RelayList.outboxFlow.value, + nip65RelayList.inboxFlow.value, + privateOutboxRelayList.flow.value, + localRelayList.flow.value, + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedServerListState.kt new file mode 100644 index 000000000..995ca9a61 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedServerListState.kt @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2025 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.model.serverList + +import android.R.attr.host +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import org.czeal.rfc3986.URIReference + +class MergedServerListState( + val fileServers: StateFlow>, + val blossomServers: StateFlow>, + val scope: CoroutineScope, +) { + fun host(url: String): String = + try { + URIReference.parse(url).host.value + } catch (e: Exception) { + url + } + + fun mergeServerList( + nip96: List?, + blossom: List?, + ): List { + val nip96servers = nip96?.map { ServerName(host(it), it, ServerType.NIP96) } ?: emptyList() + val blossomServers = blossom?.map { ServerName(host(it), it, ServerType.Blossom) } ?: emptyList() + + val result = (nip96servers + blossomServers).ifEmpty { DEFAULT_MEDIA_SERVERS } + + return result + ServerName("NIP95", "", ServerType.NIP95) + } + + val liveServerList: StateFlow> by lazy { + combine(fileServers, blossomServers) { nip96s, blossoms -> + mergeServerList(nip96s, blossoms) + } + .onStart { emit(mergeServerList(fileServers.value, blossomServers.value)) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/TrustedRelayListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/TrustedRelayListsState.kt new file mode 100644 index 000000000..95afd5395 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/TrustedRelayListsState.kt @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2025 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.model.serverList + +import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState +import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState +import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState +import com.vitorpamplona.amethyst.model.nip50Search.SearchRelayListState +import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class TrustedRelayListsState( + val nip65RelayList: Nip65RelayListState, + val privateOutboxRelayList: PrivateStorageRelayListState, + val localRelayList: LocalRelayListState, + val dmRelayList: DmRelayListState, + val searchRelayListState: SearchRelayListState, + val scope: CoroutineScope, +) { + fun mergeLists( + nip65: Set, + private: Set, + local: Set, + dm: Set, + search: Set, + ): Set { + return nip65 + private + local + dm + search + } + + val flow: StateFlow> = + combine( + nip65RelayList.allFlow, + privateOutboxRelayList.flow, + localRelayList.flow, + dmRelayList.flow, + searchRelayListState.flow, + ::mergeLists, + ) + .onStart { + emit( + mergeLists( + nip65RelayList.allFlow.value, + privateOutboxRelayList.flow.value, + localRelayList.flow.value, + dmRelayList.flow.value, + searchRelayListState.flow.value, + ), + ) + } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + mergeLists( + nip65RelayList.allFlow.value, + privateOutboxRelayList.flow.value, + localRelayList.flow.value, + dmRelayList.flow.value, + searchRelayListState.flow.value, + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/CommunityRelayLoader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/CommunityRelayLoader.kt new file mode 100644 index 000000000..1d11b9680 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/CommunityRelayLoader.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.utils.mapOfSet +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlin.collections.forEach +import kotlin.collections.ifEmpty + +class CommunityRelayLoader { + companion object { + fun communitiesPerRelay( + communityNotes: Array, + cache: LocalCache, + ): Map> { + return mapOfSet { + communityNotes.forEach { communityNote -> + val relays = + (communityNote.note.event as? CommunityDefinitionEvent)?.relayUrls() + ?.ifEmpty { null } + ?: cache.relayHints.hintsForAddress(communityNote.note.idHex) + + relays.forEach { + add(it, communityNote.note.idHex) + } + } + } + } + + fun communitiesPerRelaySnapshot( + communities: Set, + cache: LocalCache, + transformation: (Map>) -> T, + ): T { + val noteMetadata = + communities.mapNotNull { addressId -> + cache.checkGetOrCreateAddressableNote(addressId)?.flow()?.metadata?.stateFlow?.value + }.toTypedArray() + return transformation(communitiesPerRelay(noteMetadata, cache)) + } + + fun toCommunitiesPerRelayFlow( + communities: Set, + cache: LocalCache, + transformation: (Map>) -> T, + ): Flow { + val noteMetadataFlows = + communities.mapNotNull { addressId -> + cache.checkGetOrCreateAddressableNote(addressId)?.flow()?.metadata?.stateFlow + } + + return combine(noteMetadataFlows) { communityNotes -> + transformation(communitiesPerRelay(communityNotes, cache)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt new file mode 100644 index 000000000..4f7fa4750 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds + +import com.vitorpamplona.amethyst.model.ALL_FOLLOWS +import com.vitorpamplona.amethyst.model.AROUND_ME +import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.unknown.UnknownFeedFlow +import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.CoroutineScope +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.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +class FeedTopNavFilterState( + val feedFilterListName: MutableStateFlow, + val allFollows: StateFlow, + val locationFlow: StateFlow, + val followsRelays: StateFlow>, + val signer: NostrSigner, + val scope: CoroutineScope, +) { + fun loadFlowsFor(listName: String): IFeedFlowsType = + when (listName) { + GLOBAL_FOLLOWS -> GlobalFeedFlow(followsRelays) + ALL_FOLLOWS -> AllFollowsFeedFlow(allFollows, followsRelays) + AROUND_ME -> AroundMeFeedFlow(locationFlow, followsRelays) + else -> { + val note = LocalCache.checkGetOrCreateAddressableNote(listName) + if (note != null) { + NoteFeedFlow(note.flow().metadata.stateFlow, signer, followsRelays) + } else { + UnknownFeedFlow(listName) + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow = + feedFilterListName.transformLatest { listName -> + emitAll(loadFlowsFor(listName).flow()) + } + .onStart { + loadFlowsFor(feedFilterListName.value).startValue(this) + } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + AuthorsByOutboxTopNavFilter(emptySet()), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayServiceStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedFlowsType.kt similarity index 78% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayServiceStatus.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedFlowsType.kt index 99c319186..5dc1a6b45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayServiceStatus.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedFlowsType.kt @@ -18,16 +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.service.relays +package com.vitorpamplona.amethyst.model.topNavFeeds -import com.vitorpamplona.ammolite.relays.NostrClient +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector -sealed class RelayServiceStatus { - data class Active( - val client: NostrClient, - ) : RelayServiceStatus() +interface IFeedFlowsType { + fun flow(): Flow - object Off : RelayServiceStatus() - - object Connecting : RelayServiceStatus() + suspend fun startValue(collector: FlowCollector) } diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/IPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavFilter.kt similarity index 72% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/IPerRelayFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavFilter.kt index 007153438..2ec3d0a43 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/IPerRelayFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavFilter.kt @@ -18,23 +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.ammolite.relays.filters +package com.vitorpamplona.amethyst.model.topNavFeeds +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.Flow -interface IPerRelayFilter { - fun toRelay(forRelay: String): Filter +interface IFeedTopNavFilter { + fun matchAuthor(pubkey: HexKey): Boolean - fun toJson(forRelay: String): String + fun match(noteEvent: Event): Boolean - fun match( - event: Event, - forRelay: String, - ): Boolean + fun toPerRelayFlow(cache: LocalCache): Flow - fun toDebugJson(): String - - // This only exists because some relays confuse empty lists with null lists - fun isValidFor(url: String): Boolean + fun startValue(cache: LocalCache): IFeedTopNavPerRelayFilterSet } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilter.kt new file mode 100644 index 000000000..4cbff4957 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilter.kt @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds + +interface IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt new file mode 100644 index 000000000..5f922fbeb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds + +interface IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/MergedTopFeedAuthorListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/MergedTopFeedAuthorListsState.kt new file mode 100644 index 000000000..8d125d98a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/MergedTopFeedAuthorListsState.kt @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.mapOfSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class MergedTopFeedAuthorListsState( + val homeNavFilter: StateFlow, + val videoNavFilter: StateFlow, + val discoveryNavFilter: StateFlow, + val notificationNavFilter: StateFlow, + val scope: CoroutineScope, +) { + fun authorList(navFilter: IFeedTopNavPerRelayFilterSet): Map?> { + return when (navFilter) { + is AllCommunitiesTopNavPerRelayFilterSet -> emptyMap() + is AllFollowsByOutboxTopNavPerRelayFilterSet -> navFilter.set.mapValues { it.value.authors } + is AuthorsByOutboxTopNavPerRelayFilterSet -> navFilter.set.mapValues { it.value.authors } + is GlobalTopNavPerRelayFilterSet -> emptyMap() + is HashtagTopNavPerRelayFilterSet -> emptyMap() + is LocationTopNavPerRelayFilterSet -> emptyMap() + is MutedAuthorsByOutboxTopNavPerRelayFilterSet -> navFilter.set.mapValues { it.value.authors } + is SingleCommunityTopNavPerRelayFilterSet -> navFilter.set.mapValues { it.value.authors } + else -> emptyMap() + } + } + + fun mergeLists( + homeNavFilter: IFeedTopNavPerRelayFilterSet, + videoNavFilter: IFeedTopNavPerRelayFilterSet, + discoveryNavFilter: IFeedTopNavPerRelayFilterSet, + notificationNavFilter: IFeedTopNavPerRelayFilterSet, + ): Map> { + return mapOfSet { + authorList(homeNavFilter).forEach { (relay, authors) -> + authors?.let { add(relay, authors) } + } + + authorList(videoNavFilter).forEach { (relay, authors) -> + authors?.let { add(relay, authors) } + } + + authorList(discoveryNavFilter).forEach { (relay, authors) -> + authors?.let { add(relay, authors) } + } + + authorList(notificationNavFilter).forEach { (relay, authors) -> + authors?.let { add(relay, authors) } + } + } + } + + val flow: StateFlow>> = + combine( + homeNavFilter, + videoNavFilter, + discoveryNavFilter, + notificationNavFilter, + ::mergeLists, + ).onStart { + emit( + mergeLists( + homeNavFilter.value, + videoNavFilter.value, + discoveryNavFilter.value, + notificationNavFilter.value, + ), + ) + } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyMap(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt new file mode 100644 index 000000000..9a68e1e63 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.unknown.UnknownTopNavPerRelayFilterSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +class OutboxLoaderState( + topNavFilter: StateFlow, + cache: LocalCache, + scope: CoroutineScope, +) { + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow = + topNavFilter.transformLatest { filterSettings -> + emitAll(filterSettings.toPerRelayFlow(cache)) + }.onStart { + emit(topNavFilter.value.startValue(cache)) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Companion.Eagerly, + UnknownTopNavPerRelayFilterSet, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt new file mode 100644 index 000000000..11e520899 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.mapOfSet +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlin.collections.ifEmpty + +class OutboxRelayLoader { + companion object { + private fun authorsPerRelay( + outboxRelayNotes: Array, + cache: LocalCache, + ): Map> { + return mapOfSet { + outboxRelayNotes.forEach { outboxNote -> + val relays = + (outboxNote.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()?.ifEmpty { null } + ?: outboxNote.note.author?.pubkeyHex ?.let { cache.relayHints.hintsForKey(it) } + + relays?.forEach { + add(it, outboxNote.note.idHex) + } + } + } + } + + fun authorsPerRelaySnapshot( + authors: Set, + cache: LocalCache, + transformation: (Map>) -> T, + ): T { + val noteMetadata = + authors.map { pubkeyHex -> + cache.getOrCreateAddressableNote(AdvertisedRelayListEvent.createAddress(pubkeyHex)).flow().metadata.stateFlow.value + }.toTypedArray() + return transformation(authorsPerRelay(noteMetadata, cache)) + } + + fun toAuthorsPerRelayFlow( + authors: Set, + cache: LocalCache, + transformation: (Map>) -> T, + ): Flow { + val noteMetadataFlows = + authors.map { pubkeyHex -> + val note = cache.getOrCreateAddressableNote(AdvertisedRelayListEvent.createAddress(pubkeyHex)) + note.flow().metadata.stateFlow + } + + return combine(noteMetadataFlows) { outboxRelays -> + transformation(authorsPerRelay(outboxRelays, cache)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt new file mode 100644 index 000000000..2d6515f07 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt @@ -0,0 +1,140 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.allFollows + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.CommunityRelayLoader +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNotes +import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHashes +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHashes +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +/** + * This is a big OR filter on all fields. + */ +@Immutable +class AllFollowsByOutboxTopNavFilter( + val authors: Set? = null, + val hashtags: Set? = null, + val geotags: Set? = null, + val communities: Set? = null, + val defaultRelays: StateFlow>, +) : IFeedTopNavFilter { + val geotagScopes: Set? = geotags?.mapTo(mutableSetOf()) { GeohashId.Companion.toScope(it) } + val hashtagScopes: Set? = hashtags?.mapTo(mutableSetOf()) { HashtagId.Companion.toScope(it) } + + override fun matchAuthor(pubkey: HexKey): Boolean { + return authors == null || pubkey in authors + } + + override fun match(noteEvent: Event): Boolean { + return if (noteEvent is LiveActivitiesEvent) { + (authors != null && noteEvent.participantsIntersect(authors)) || + (hashtags != null && noteEvent.isTaggedHashes(hashtags)) || + (geotags != null && noteEvent.isTaggedGeoHashes(geotags)) || + (communities != null && noteEvent.isTaggedAddressableNotes(communities)) + } else if (noteEvent is CommentEvent) { + // ignore follows and checks only the root scope + (authors != null && noteEvent.pubKey in authors) || + (hashtags != null && noteEvent.isTaggedHashes(hashtags)) || + (hashtagScopes != null && noteEvent.isTaggedScopes(hashtagScopes)) || + (geotags != null && noteEvent.isTaggedGeoHashes(geotags)) || + (geotagScopes != null && noteEvent.isTaggedScopes(geotagScopes)) || + (communities != null && noteEvent.isTaggedAddressableNotes(communities)) + } else { + (authors != null && noteEvent.pubKey in authors) || + (hashtags != null && noteEvent.isTaggedHashes(hashtags)) || + (geotags != null && noteEvent.isTaggedGeoHashes(geotags)) || + (communities != null && noteEvent.isTaggedAddressableNotes(communities)) + } + } + + override fun toPerRelayFlow(cache: LocalCache): Flow { + val authorsPerRelay = + if (authors != null) { + OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) { it } + } else { + MutableStateFlow(emptyMap()) + } + val communitiesPerRelay = + if (communities != null) { + CommunityRelayLoader.toCommunitiesPerRelayFlow(communities, cache) { it } + } else { + MutableStateFlow(emptyMap()) + } + + return combine(authorsPerRelay, communitiesPerRelay, defaultRelays) { perRelayAuthors, perRelayCommunities, default -> + val allRelays = (perRelayAuthors.keys + perRelayCommunities.keys).ifEmpty { default } + + AllFollowsByOutboxTopNavPerRelayFilterSet( + allRelays.associateWith { + AllFollowsByOutboxTopNavPerRelayFilter( + authors = perRelayAuthors[it], + hashtags = hashtags, + geotags = geotags, + communities = perRelayCommunities[it], + ) + }, + ) + } + } + + override fun startValue(cache: LocalCache): AllFollowsByOutboxTopNavPerRelayFilterSet { + val authorsPerRelay = + if (authors != null) { + OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) { it } + } else { + emptyMap() + } + val communitiesPerRelay = + if (communities != null) { + CommunityRelayLoader.communitiesPerRelaySnapshot(communities, cache) { it } + } else { + emptyMap() + } + + val allRelays = (authorsPerRelay.keys + communitiesPerRelay.keys).ifEmpty { defaultRelays.value } + + return AllFollowsByOutboxTopNavPerRelayFilterSet( + allRelays.associateWith { + AllFollowsByOutboxTopNavPerRelayFilter( + authors = authorsPerRelay[it], + hashtags = hashtags, + geotags = geotags, + communities = communitiesPerRelay[it], + ) + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3BasicRelaySetupInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavPerRelayFilter.kt similarity index 60% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3BasicRelaySetupInfo.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavPerRelayFilter.kt index 49db64fce..49f1aa7e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3BasicRelaySetupInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavPerRelayFilter.kt @@ -18,21 +18,23 @@ * 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.kind3 +package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows import androidx.compose.runtime.Immutable -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.quartz.nip01Core.relay.RelayStat +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +/** + * This is a big OR filter. + */ @Immutable -data class Kind3BasicRelaySetupInfo( - val url: String, - val read: Boolean, - val write: Boolean, - val feedTypes: Set, - val relayStat: RelayStat, - val paidRelay: Boolean = false, -) { - val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url) +class AllFollowsByOutboxTopNavPerRelayFilter( + val authors: Set? = null, + val hashtags: Set? = null, + val geotags: Set? = null, + val communities: Set? = null, +) : IFeedTopNavPerRelayFilter { + val geotagScopes: Set? = geotags?.mapTo(mutableSetOf()) { GeohashId.toScope(it) } + val hashtagScopes: Set? = hashtags?.mapTo(mutableSetOf()) { HashtagId.toScope(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavPerRelayFilterSet.kt new file mode 100644 index 000000000..7352e976b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.allFollows + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class AllFollowsByOutboxTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsFeedFlow.kt new file mode 100644 index 000000000..ed162f657 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsFeedFlow.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.allFollows + +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map + +class AllFollowsFeedFlow( + val allFollows: StateFlow, + val followsRelays: StateFlow>, +) : IFeedFlowsType { + fun convert(kind3: FollowListState.Kind3Follows?): AllFollowsByOutboxTopNavFilter { + return if (kind3 != null) { + AllFollowsByOutboxTopNavFilter( + authors = kind3.authors, + hashtags = kind3.hashtags, + geotags = kind3.geotags, + communities = kind3.communities, + defaultRelays = followsRelays, + ) + } else { + AllFollowsByOutboxTopNavFilter( + authors = emptySet(), + defaultRelays = followsRelays, + ) + } + } + + override fun flow() = allFollows.map(::convert) + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(convert(allFollows.value)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt new file mode 100644 index 000000000..028931a18 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.aroundMe + +import com.fonfon.kgeohash.GeoHash + +fun compute50kmLine(geoHash: GeoHash): List { + val hashes = mutableListOf() + + hashes.add(geoHash.toString()) + + var currentGeoHash = geoHash + repeat(5) { + currentGeoHash = currentGeoHash.westernNeighbour + hashes.add(currentGeoHash.toString()) + } + + currentGeoHash = geoHash + repeat(5) { + currentGeoHash = currentGeoHash.easternNeighbour + hashes.add(currentGeoHash.toString()) + } + + return hashes +} + +fun compute50kmRange(geoHash: GeoHash): List { + val hashes = mutableListOf() + + hashes.addAll(compute50kmLine(geoHash)) + + var currentGeoHash = geoHash + repeat(5) { + currentGeoHash = currentGeoHash.northernNeighbour + hashes.addAll(compute50kmLine(currentGeoHash)) + } + + currentGeoHash = geoHash + repeat(5) { + currentGeoHash = currentGeoHash.southernNeighbour + hashes.addAll(compute50kmLine(currentGeoHash)) + } + + return hashes +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeFeedFlow.kt new file mode 100644 index 000000000..51207c028 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeFeedFlow.kt @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.aroundMe + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map + +class AroundMeFeedFlow( + val location: StateFlow, + val allFollowRelays: StateFlow>, +) : IFeedFlowsType { + fun convert(result: LocationState.LocationResult): LocationTopNavFilter { + return if (result is LocationState.LocationResult.Success) { + // 2 neighbors deep = 25x25km + LocationTopNavFilter( + geotags = compute50kmRange(result.geoHash).toSet(), + relays = allFollowRelays, + ) + } else { + // empty feed until we have a successful geohash + LocationTopNavFilter( + geotags = emptySet(), + relays = allFollowRelays, + ) + } + } + + override fun flow() = location.map(::convert) + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(convert(location.value)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt new file mode 100644 index 000000000..3016de162 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.aroundMe + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHashes +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map + +@Immutable +class LocationTopNavFilter( + val geotags: Set, + val relays: StateFlow>, +) : IFeedTopNavFilter { + val geotagScopes: Set = geotags.mapTo(mutableSetOf()) { GeohashId.Companion.toScope(it) } + + override fun matchAuthor(pubkey: HexKey): Boolean = true + + override fun match(noteEvent: Event): Boolean { + if (geotags.isEmpty()) return false + + return if (noteEvent is CommentEvent) { + noteEvent.isTaggedGeoHashes(geotags) || + noteEvent.isTaggedScopes(geotagScopes) + } else { + noteEvent.isTaggedGeoHashes(geotags) + } + } + + override fun toPerRelayFlow(cache: LocalCache): Flow { + return relays.map { + LocationTopNavPerRelayFilterSet(it.associateWith { LocationTopNavPerRelayFilter(geotags) }) + } + } + + override fun startValue(cache: LocalCache): LocationTopNavPerRelayFilterSet { + return LocationTopNavPerRelayFilterSet(relays.value.associateWith { LocationTopNavPerRelayFilter(geotags) }) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt new file mode 100644 index 000000000..219c10719 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.aroundMe + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId + +@Immutable +class LocationTopNavPerRelayFilter( + val geotags: Set, +) : IFeedTopNavPerRelayFilter { + val geotagScopes: Set = geotags.mapTo(mutableSetOf()) { GeohashId.toScope(it) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt new file mode 100644 index 000000000..85564aac7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.aroundMe + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class LocationTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayBriefInfoCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalFeedFlow.kt similarity index 61% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayBriefInfoCache.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalFeedFlow.kt index e57f45780..0a2141df2 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayBriefInfoCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalFeedFlow.kt @@ -18,29 +18,23 @@ * 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.amethyst.model.topNavFeeds.global -import android.util.LruCache -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow -object RelayBriefInfoCache { - val cache = LruCache(50) +class GlobalFeedFlow( + val relays: StateFlow>, +) : IFeedFlowsType { + val default = GlobalTopNavFilter(relays) - @Immutable - class RelayBriefInfo( - val url: String, - ) { - val displayUrl: String = RelayUrlFormatter.displayUrl(url).intern() - val favIcon: String = "https://$displayUrl/favicon.ico".intern() - } + override fun flow() = MutableStateFlow(default) - fun get(url: String): RelayBriefInfo { - val info = cache[url] - if (info != null) return info - - val newInfo = RelayBriefInfo(url) - cache.put(url, newInfo) - return newInfo + override suspend fun startValue(collector: FlowCollector) { + collector.emit(default) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavFilter.kt new file mode 100644 index 000000000..a8ab72b43 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavFilter.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.global + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map + +@Immutable +class GlobalTopNavFilter( + val relays: StateFlow>, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey): Boolean = true + + override fun match(noteEvent: Event) = true + + override fun toPerRelayFlow(cache: LocalCache): Flow { + return relays.map { + GlobalTopNavPerRelayFilterSet(it.associateWith { GlobalTopNavPerRelayFilter }) + } + } + + override fun startValue(cache: LocalCache): GlobalTopNavPerRelayFilterSet { + return GlobalTopNavPerRelayFilterSet( + relays.value.associateWith { GlobalTopNavPerRelayFilter }, + ) + } +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/TypedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilter.kt similarity index 77% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/TypedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilter.kt index 6e6201042..772a298dc 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/TypedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilter.kt @@ -18,14 +18,10 @@ * 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.amethyst.model.topNavFeeds.global -import com.vitorpamplona.ammolite.relays.filters.IPerRelayFilter +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter -class TypedFilter( - val types: Set, - val filter: IPerRelayFilter, -) { - // This only exists because some relays confuse empty lists with null lists - fun isValidFor(url: String) = filter.isValidFor(url) -} +@Immutable +object GlobalTopNavPerRelayFilter : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt new file mode 100644 index 000000000..2e30e5dc5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.global + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class GlobalTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavFilter.kt new file mode 100644 index 000000000..c261a3458 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavFilter.kt @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.hashtag + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHashes +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map + +@Immutable +class HashtagTopNavFilter( + val hashtags: Set, + val relays: StateFlow>, +) : IFeedTopNavFilter { + val hashtagScopes: Set = hashtags.mapTo(mutableSetOf()) { HashtagId.toScope(it) } + + override fun matchAuthor(pubkey: HexKey): Boolean = true + + override fun match(noteEvent: Event): Boolean { + return if (noteEvent is CommentEvent) { + noteEvent.isTaggedHashes(hashtags) || noteEvent.isTaggedScopes(hashtagScopes) + } else { + noteEvent.isTaggedHashes(hashtags) + } + } + + override fun toPerRelayFlow(cache: LocalCache): Flow { + return relays.map { + HashtagTopNavPerRelayFilterSet( + it.associateWith { HashtagTopNavPerRelayFilter(hashtags) }, + ) + } + } + + override fun startValue(cache: LocalCache): HashtagTopNavPerRelayFilterSet { + return HashtagTopNavPerRelayFilterSet( + relays.value.associateWith { + HashtagTopNavPerRelayFilter(hashtags) + }, + ) + } +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelaySetupInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilter.kt similarity index 74% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelaySetupInfo.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilter.kt index 7d5868018..8c5a0406f 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelaySetupInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilter.kt @@ -18,23 +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.ammolite.relays +package com.vitorpamplona.amethyst.model.topNavFeeds.hashtag import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId @Immutable -data class RelaySetupInfo( - val url: String, - val read: Boolean, - val write: Boolean, - val feedTypes: Set, -) - -@Immutable -data class RelaySetupInfoToConnect( - val url: String, - val forceProxy: Boolean, - val read: Boolean, - val write: Boolean, - val feedTypes: Set, -) +class HashtagTopNavPerRelayFilter( + val hashtags: Set, +) : IFeedTopNavPerRelayFilter { + val hashtagScopes: Set = hashtags.mapTo(mutableSetOf()) { HashtagId.toScope(it) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt new file mode 100644 index 000000000..35c5481b0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.hashtag + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class HashtagTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/NoteFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/NoteFeedFlow.kt new file mode 100644 index 000000000..b30f72358 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/NoteFeedFlow.kt @@ -0,0 +1,131 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.noteBased + +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.transformLatest + +class NoteFeedFlow( + val metadataFlow: StateFlow, + val signer: NostrSigner, + val allFollowRelays: StateFlow>, +) : IFeedFlowsType { + suspend fun FlowCollector.process(noteEvent: Event) { + when (noteEvent) { + is PeopleListEvent -> { + if (noteEvent.dTag() == PeopleListEvent.Companion.BLOCK_LIST_D_TAG) { + emit(MutedAuthorsByOutboxTopNavFilter(noteEvent.publicUsersAndWords().users)) + + noteEvent.publicAndPrivateUsersAndWords(signer)?.let { + emit(MutedAuthorsByOutboxTopNavFilter(it.users)) + } + } else { + emit(AuthorsByOutboxTopNavFilter(noteEvent.publicUsersAndWords().users)) + + noteEvent.publicAndPrivateUsersAndWords(signer)?.let { + emit(AuthorsByOutboxTopNavFilter(it.users)) + } + } + } + is MuteListEvent -> { + emit(MutedAuthorsByOutboxTopNavFilter(noteEvent.publicUsersAndWords().users)) + + noteEvent.publicAndPrivateUsersAndWords(signer)?.let { + emit(MutedAuthorsByOutboxTopNavFilter(it.users)) + } + } + is FollowListEvent -> { + emit(AuthorsByOutboxTopNavFilter(noteEvent.pubKeys().toSet())) + } + is CommunityListEvent -> { + emit(AllCommunitiesTopNavFilter(noteEvent.publicCommunityIds().toSet())) + + noteEvent.publicAndPrivateCommunities(signer)?.let { + val communities = it.map { it.addressId }.toSet() + emit(AllCommunitiesTopNavFilter(communities)) + } + } + is HashtagListEvent -> { + emit(HashtagTopNavFilter(noteEvent.publicHashtags().toSet(), allFollowRelays)) + + noteEvent.publicAndPrivateHashtag(signer)?.let { + emit(HashtagTopNavFilter(it, allFollowRelays)) + } + } + is GeohashListEvent -> { + emit(LocationTopNavFilter(noteEvent.publicGeohashes().toSet(), allFollowRelays)) + + noteEvent.publicAndPrivateGeohash(signer)?.let { + emit(LocationTopNavFilter(it, allFollowRelays)) + } + } + is CommunityDefinitionEvent -> { + SingleCommunityTopNavFilter( + community = noteEvent.addressTag(), + authors = noteEvent.moderatorKeys().toSet().ifEmpty { null }, + relays = noteEvent.relayUrls().toSet(), + ) + } + else -> AuthorsByOutboxTopNavFilter(emptySet()) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun flow() = + metadataFlow.transformLatest { noteState -> + val noteEvent = noteState?.note?.event + if (noteEvent == null) { + AuthorsByOutboxTopNavFilter(emptySet()) + } else { + process(noteEvent) + } + } + + override suspend fun startValue(collector: FlowCollector) { + val noteEvent = metadataFlow.value?.note?.event + if (noteEvent == null) { + AuthorsByOutboxTopNavFilter(emptySet()) + } else { + collector.process(noteEvent) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt new file mode 100644 index 000000000..0dca95f15 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.noteBased.allcommunities + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.CommunityRelayLoader +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNotes +import kotlinx.coroutines.flow.Flow + +@Immutable +class AllCommunitiesTopNavFilter( + val communities: Set, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey): Boolean = true + + override fun match(noteEvent: Event): Boolean { + return noteEvent.isTaggedAddressableNotes(communities) + } + + fun convert(map: Map>) = + AllCommunitiesTopNavPerRelayFilterSet( + map.mapValues { AllCommunitiesTopNavPerRelayFilter(it.value) }, + ) + + override fun toPerRelayFlow(cache: LocalCache): Flow { + return CommunityRelayLoader.toCommunitiesPerRelayFlow(communities, cache) { + convert(it) + } + } + + override fun startValue(cache: LocalCache): AllCommunitiesTopNavPerRelayFilterSet { + return CommunityRelayLoader.communitiesPerRelaySnapshot(communities, cache) { + convert(it) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilter.kt new file mode 100644 index 000000000..dd532c3e9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilter.kt @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.noteBased.allcommunities + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter + +class AllCommunitiesTopNavPerRelayFilter( + val communities: Set, +) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt new file mode 100644 index 000000000..061c40e5a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.noteBased.allcommunities + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class AllCommunitiesTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt new file mode 100644 index 000000000..14697499b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.noteBased.author + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import kotlinx.coroutines.flow.Flow + +@Immutable +class AuthorsByOutboxTopNavFilter( + val authors: Set, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey) = pubkey in authors + + override fun match(noteEvent: Event): Boolean { + return if (noteEvent is LiveActivitiesEvent) { + noteEvent.participantsIntersect(authors) + } else { + noteEvent.pubKey in authors + } + } + + fun convert(map: Map>) = + AuthorsByOutboxTopNavPerRelayFilterSet( + map.mapValues { AuthorsByOutboxTopNavPerRelayFilter(it.value) }, + ) + + override fun toPerRelayFlow(cache: LocalCache): Flow { + return OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache, ::convert) + } + + override fun startValue(cache: LocalCache): AuthorsByOutboxTopNavPerRelayFilterSet { + return OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache, ::convert) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavPerRelayFilter.kt new file mode 100644 index 000000000..1c1d215ea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavPerRelayFilter.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.noteBased.author + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter + +@Immutable +class AuthorsByOutboxTopNavPerRelayFilter( + val authors: Set, +) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavPerRelayFilterSet.kt new file mode 100644 index 000000000..4564dc5fb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.noteBased.author + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class AuthorsByOutboxTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt new file mode 100644 index 000000000..3d7016192 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt @@ -0,0 +1,115 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.noteBased.community + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +@Immutable +class SingleCommunityTopNavFilter( + val community: String, + val authors: Set?, + val relays: Set, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey) = authors == null || pubkey in authors + + override fun match(noteEvent: Event): Boolean { + return if (noteEvent is LiveActivitiesEvent) { + (authors != null && noteEvent.participantsIntersect(authors)) || noteEvent.isTaggedAddressableNote(community) + } else if (noteEvent is CommentEvent) { + (authors != null && noteEvent.pubKey in authors) || noteEvent.isTaggedAddressableNote(community) + } else { + (authors != null && noteEvent.pubKey in authors) || noteEvent.isTaggedAddressableNote(community) + } + } + + override fun toPerRelayFlow(cache: LocalCache): Flow { + // relay field takes priority + if (relays.isNotEmpty()) { + return MutableStateFlow( + SingleCommunityTopNavPerRelayFilterSet( + relays.associateWith { + SingleCommunityTopNavPerRelayFilter(community, authors) + }, + ), + ) + } + + if (authors != null) { + // go by authors + return OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) { + SingleCommunityTopNavPerRelayFilterSet( + it.mapValues { + SingleCommunityTopNavPerRelayFilter(community, it.value) + }, + ) + } + } + + // go by hints + return MutableStateFlow( + SingleCommunityTopNavPerRelayFilterSet( + cache.relayHints.hintsForAddress(community).associateWith { + SingleCommunityTopNavPerRelayFilter(community, authors) + }, + ), + ) + } + + override fun startValue(cache: LocalCache): SingleCommunityTopNavPerRelayFilterSet { + // relay field takes priority + if (relays.isNotEmpty()) { + return SingleCommunityTopNavPerRelayFilterSet( + relays.associateWith { + SingleCommunityTopNavPerRelayFilter(community, authors) + }, + ) + } + + if (authors != null) { + // go by authors + return OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) { + SingleCommunityTopNavPerRelayFilterSet( + it.mapValues { + SingleCommunityTopNavPerRelayFilter(community, it.value) + }, + ) + } + } + + // go by hints + return SingleCommunityTopNavPerRelayFilterSet( + cache.relayHints.hintsForAddress(community).associateWith { + SingleCommunityTopNavPerRelayFilter(community, authors) + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilter.kt new file mode 100644 index 000000000..596af8a4c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilter.kt @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.noteBased.community + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter + +@Immutable +class SingleCommunityTopNavPerRelayFilter( + val community: String, + val authors: Set?, +) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt new file mode 100644 index 000000000..c376579fa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.noteBased.community + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class SingleCommunityTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt new file mode 100644 index 000000000..d98676d6d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.noteBased.muted + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import kotlinx.coroutines.flow.Flow + +@Immutable +class MutedAuthorsByOutboxTopNavFilter( + val authors: Set, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey) = pubkey in authors + + override fun match(noteEvent: Event): Boolean { + return if (noteEvent is LiveActivitiesEvent) { + noteEvent.participantsIntersect(authors) + } else { + noteEvent.pubKey in authors + } + } + + fun convert(map: Map>) = + MutedAuthorsByOutboxTopNavPerRelayFilterSet( + map.mapValues { MutedAuthorsByOutboxTopNavPerRelayFilter(it.value) }, + ) + + override fun toPerRelayFlow(cache: LocalCache): Flow { + return OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache, ::convert) + } + + override fun startValue(cache: LocalCache): MutedAuthorsByOutboxTopNavPerRelayFilterSet { + return OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache, ::convert) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavPerRelayFilter.kt new file mode 100644 index 000000000..1ee39cc47 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavPerRelayFilter.kt @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.noteBased.muted + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter + +@Immutable +class MutedAuthorsByOutboxTopNavPerRelayFilter( + val authors: Set, +) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavPerRelayFilterSet.kt new file mode 100644 index 000000000..e2760be42 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.noteBased.muted + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class MutedAuthorsByOutboxTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownFeedFlow.kt new file mode 100644 index 000000000..4596d3098 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownFeedFlow.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 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.model.topNavFeeds.unknown + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow + +class UnknownFeedFlow( + val feedName: String, +) : IFeedFlowsType { + override fun flow() = MutableStateFlow(UnknownTopNavFilter(feedName)) + + // empty feed + override suspend fun startValue(collector: FlowCollector) { + collector.emit(UnknownTopNavFilter(feedName)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelayProposalSetupInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt similarity index 62% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelayProposalSetupInfo.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt index e1bbd9e1d..0699da58b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelayProposalSetupInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt @@ -18,23 +18,27 @@ * 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.recommendations +package com.vitorpamplona.amethyst.model.topNavFeeds.unknown import androidx.compose.runtime.Immutable -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.RelayStat +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow @Immutable -data class Kind3RelayProposalSetupInfo( - val url: String, - val read: Boolean, - val write: Boolean, - val feedTypes: Set, - val relayStat: RelayStat, - val paidRelay: Boolean = false, - val users: List, -) { - val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url) +class UnknownTopNavFilter( + val feedName: String, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey) = false + + override fun match(noteEvent: Event) = false + + override fun toPerRelayFlow(cache: LocalCache): Flow { + return MutableStateFlow(UnknownTopNavPerRelayFilterSet) + } + + override fun startValue(cache: LocalCache) = UnknownTopNavPerRelayFilterSet } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt similarity index 83% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt index b94ee3de8..556f596fc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt @@ -18,15 +18,8 @@ * 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.quartz.nip01Core.relay +package com.vitorpamplona.amethyst.model.topNavFeeds.unknown -enum class RelayState { - // Websocket connected - CONNECTED, +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet - // Websocket disconnecting - DISCONNECTING, - - // Websocket disconnected - DISCONNECTED, -} +object UnknownTopNavPerRelayFilterSet : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluation.kt new file mode 100644 index 000000000..db2277eb8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluation.kt @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2025 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.model.torState + +import com.vitorpamplona.amethyst.ui.tor.TorType +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion + +class TorRelayEvaluation( + val torSettings: TorRelaySettings, + val trustedRelayList: Set, + val dmRelayList: Set, +) { + fun useTor(relay: NormalizedRelayUrl): Boolean { + return if (torSettings.torType == TorType.OFF) { + false + } else { + if (relay.isLocalHost()) { + false + } else if (relay.isOnion()) { + torSettings.onionRelaysViaTor + } else if (relay in dmRelayList) { + torSettings.dmRelaysViaTor + } else if (relay in trustedRelayList) { + torSettings.trustedRelaysViaTor + } else { + torSettings.newRelaysViaTor + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelaySettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelaySettings.kt new file mode 100644 index 000000000..77a59f42c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelaySettings.kt @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2025 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.model.torState + +import com.vitorpamplona.amethyst.ui.tor.TorType + +class TorRelaySettings( + val torType: TorType = TorType.OFF, + val onionRelaysViaTor: Boolean = true, + val dmRelaysViaTor: Boolean = false, + val trustedRelaysViaTor: Boolean = false, + val newRelaysViaTor: Boolean = false, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt new file mode 100644 index 000000000..daa838448 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt @@ -0,0 +1,107 @@ +/** + * Copyright (c) 2025 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.model.torState + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState +import com.vitorpamplona.amethyst.model.serverList.TrustedRelayListsState +import com.vitorpamplona.amethyst.ui.tor.TorType +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class TorRelayState( + val trustedRelayState: TrustedRelayListsState, + val dmRelayState: DmRelayListState, + val settings: AccountSettings, + val scope: CoroutineScope, +) { + val torSettings = + combine( + settings.torSettings.torType, + settings.torSettings.onionRelaysViaTor, + settings.torSettings.dmRelaysViaTor, + settings.torSettings.trustedRelaysViaTor, + settings.torSettings.newRelaysViaTor, + ) { + torType: TorType, + onionRelaysViaTor: Boolean, + dmRelaysViaTor: Boolean, + trustedRelaysViaTor: Boolean, + newRelaysViaTor: Boolean, + -> + TorRelaySettings(torType, onionRelaysViaTor, dmRelaysViaTor, trustedRelaysViaTor, newRelaysViaTor) + }.onStart { + emit( + TorRelaySettings( + settings.torSettings.torType.value, + settings.torSettings.onionRelaysViaTor.value, + settings.torSettings.dmRelaysViaTor.value, + settings.torSettings.trustedRelaysViaTor.value, + settings.torSettings.newRelaysViaTor.value, + ), + ) + } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + TorRelaySettings( + settings.torSettings.torType.value, + settings.torSettings.onionRelaysViaTor.value, + settings.torSettings.dmRelaysViaTor.value, + settings.torSettings.trustedRelaysViaTor.value, + settings.torSettings.newRelaysViaTor.value, + ), + ) + + val flow = + combineTransform( + torSettings, + trustedRelayState.flow, + dmRelayState.flow, + ) { torSettings: TorRelaySettings, trustedRelayList: Set, dmRelayList: Set -> + emit(TorRelayEvaluation(torSettings, trustedRelayList, dmRelayList)) + }.onStart { + emit( + TorRelayEvaluation( + torSettings.value, + trustedRelayState.flow.value, + dmRelayState.flow.value, + ), + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + TorRelayEvaluation( + torSettings.value, + trustedRelayState.flow.value, + dmRelayState.flow.value, + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt index 9b1d652d0..62c0d28bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt @@ -22,8 +22,9 @@ package com.vitorpamplona.amethyst.service import android.util.Log import android.util.LruCache +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CancellationException import okhttp3.Call @@ -49,25 +50,23 @@ object Nip11CachedRetriever { class RetrieveResultLoading : RetrieveResult(TimeUtils.now()) - private val relayInformationDocumentCache = LruCache(100) + private val relayInformationDocumentCache = LruCache(100) private val retriever = Nip11Retriever() - fun getFromCache(dirtyUrl: String): Nip11RelayInformation? { - val result = relayInformationDocumentCache.get(RelayUrlFormatter.getHttpsUrl(dirtyUrl)) ?: return null + fun getFromCache(relay: NormalizedRelayUrl): Nip11RelayInformation? { + val result = relayInformationDocumentCache.get(relay) ?: return null if (result is RetrieveResultSuccess) return result.data return null } suspend fun loadRelayInfo( - dirtyUrl: String, + relay: NormalizedRelayUrl, okHttpClient: (String) -> OkHttpClient, onInfo: (Nip11RelayInformation) -> Unit, - onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, + onError: (NormalizedRelayUrl, Nip11Retriever.ErrorCode, String?) -> Unit, ) { checkNotInMainThread() - val url = RelayUrlFormatter.getHttpsUrl(dirtyUrl) - val doc = relayInformationDocumentCache.get(url) - + val doc = relayInformationDocumentCache.get(relay) if (doc != null) { if (doc is RetrieveResultSuccess) { onInfo(doc.data) @@ -75,41 +74,37 @@ object Nip11CachedRetriever { if (TimeUtils.now() - doc.time < TimeUtils.ONE_MINUTE) { // just wait. } else { - retrieve(url, dirtyUrl, okHttpClient, onInfo, onError) + retrieve(relay, okHttpClient, onInfo, onError) } } else if (doc is RetrieveResultError) { if (TimeUtils.now() - doc.time < TimeUtils.ONE_HOUR) { - onError(dirtyUrl, doc.error, null) + onError(relay, doc.error, null) } else { - retrieve(url, dirtyUrl, okHttpClient, onInfo, onError) + retrieve(relay, okHttpClient, onInfo, onError) } } } else { - retrieve(url, dirtyUrl, okHttpClient, onInfo, onError) + retrieve(relay, okHttpClient, onInfo, onError) } } private suspend fun retrieve( - url: String, - dirtyUrl: String, + relay: NormalizedRelayUrl, okHttpClient: (String) -> OkHttpClient, onInfo: (Nip11RelayInformation) -> Unit, - onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, + onError: (NormalizedRelayUrl, Nip11Retriever.ErrorCode, String?) -> Unit, ) { - relayInformationDocumentCache.put(url, RetrieveResultLoading()) + relayInformationDocumentCache.put(relay, RetrieveResultLoading()) retriever.loadRelayInfo( - url = url, - dirtyUrl = dirtyUrl, + relay = relay, okHttpClient = okHttpClient, onInfo = { - checkNotInMainThread() - relayInformationDocumentCache.put(url, RetrieveResultSuccess(it)) + relayInformationDocumentCache.put(relay, RetrieveResultSuccess(it)) onInfo(it) }, - onError = { dirtyUrl, code, errorMsg -> - checkNotInMainThread() - relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg)) - onError(url, code, errorMsg) + onError = { relay, code, errorMsg -> + relayInformationDocumentCache.put(relay, RetrieveResultError(code, errorMsg)) + onError(relay, code, errorMsg) }, ) } @@ -124,13 +119,13 @@ class Nip11Retriever { } suspend fun loadRelayInfo( - url: String, - dirtyUrl: String, + relay: NormalizedRelayUrl, okHttpClient: (String) -> OkHttpClient, onInfo: (Nip11RelayInformation) -> Unit, - onError: (String, ErrorCode, String?) -> Unit, + onError: (NormalizedRelayUrl, ErrorCode, String?) -> Unit, ) { checkNotInMainThread() + val url = relay.toHttp() try { val request: Request = Request @@ -154,16 +149,16 @@ class Nip11Retriever { if (it.isSuccessful) { onInfo(Nip11RelayInformation.fromJson(body)) } else { - onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString()) + onError(relay, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString()) } } catch (e: Exception) { if (e is CancellationException) throw e Log.e( "RelayInfoFail", - "Resulting Message from Relay $dirtyUrl in not parseable: $body", + "Resulting Message from Relay ${relay.url} in not parseable: $body", e, ) - onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message) + onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, e.message) } } } @@ -172,15 +167,15 @@ class Nip11Retriever { call: Call, e: IOException, ) { - Log.e("RelayInfoFail", "$dirtyUrl unavailable", e) - onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message) + Log.e("RelayInfoFail", "${relay.url} unavailable", e) + onError(relay, ErrorCode.FAIL_TO_REACH_SERVER, e.message) } }, ) } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e) - onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message) + Log.e("RelayInfoFail", "Invalid URL ${relay.url}", e) + onError(relay, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message) } } } 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 19148b2ac..844a5e6e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -23,13 +23,14 @@ package com.vitorpamplona.amethyst.service import android.content.Context import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.collectSuccessfulOperations 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.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -39,6 +40,7 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.utils.collectSuccessfulOperations import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers @@ -173,6 +175,16 @@ class ZapPaymentHandler( val user: User? = null, ) + fun receivingRelaySet(userHex: HexKey): Set? { + return ( + LocalCache + .getAddressableNoteIfExists( + AdvertisedRelayListEvent.createAddressTag(userHex), + )?.event as? AdvertisedRelayListEvent + ) + ?.readRelaysNorm()?.toSet() + } + suspend fun signAllZapRequests( note: Note, pollOption: Int?, @@ -181,18 +193,6 @@ class ZapPaymentHandler( zapsToSend: List, onAllDone: suspend (List) -> Unit, ) { - val authorRelayList = - note.author - ?.pubkeyHex - ?.let { - ( - LocalCache - .getAddressableNoteIfExists( - AdvertisedRelayListEvent.createAddressTag(it), - )?.event as? AdvertisedRelayListEvent? - )?.readRelays() - }?.toSet() - collectSuccessfulOperations( items = zapsToSend, runRequestFor = { next: BaseZapSplitSetup, onReady -> @@ -203,18 +203,12 @@ class ZapPaymentHandler( } } } else if (next is ZapSplitSetup) { - val user = LocalCache.getUserIfExists(next.pubKeyHex) - val userRelayList = - ( - ( - LocalCache - .getAddressableNoteIfExists( - AdvertisedRelayListEvent.createAddressTag(next.pubKeyHex), - )?.event as? AdvertisedRelayListEvent? - )?.readRelays()?.toSet() ?: emptySet() - ) + (authorRelayList ?: emptySet()) + val authorRelayList = note.author?.let { receivingRelaySet(it.pubkeyHex) } ?: emptySet() + val userRelayList = receivingRelaySet(next.pubKeyHex) ?: emptySet() - prepareZapRequestIfNeeded(note, pollOption, message, zapType, user, userRelayList) { zapRequestJson -> + val user = LocalCache.getOrCreateUser(next.pubKeyHex) + + prepareZapRequestIfNeeded(note, pollOption, message, zapType, user, userRelayList + authorRelayList) { zapRequestJson -> onReady(ZapRequestReady(next, zapRequestJson, user)) } } @@ -387,7 +381,7 @@ class ZapPaymentHandler( message: String, zapType: LnZapEvent.ZapType, overrideUser: User? = null, - additionalRelays: Set? = null, + additionalRelays: Set? = null, onReady: (String?) -> Unit, ) { if (zapType != LnZapEvent.ZapType.NONZAP) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt index 5be226aeb..b5ce69cc4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt @@ -25,11 +25,12 @@ import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences -import com.vitorpamplona.amethyst.launchAndWaitAll import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.tryAndWait +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal +import com.vitorpamplona.quartz.utils.launchAndWaitAll +import com.vitorpamplona.quartz.utils.tryAndWait import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType @@ -51,7 +52,7 @@ class RegisterAccounts( private suspend fun signAllAuths( notificationToken: String, - remainingTos: List>>, + remainingTos: List>>, output: MutableList, onReady: (List) -> Unit, ) { @@ -99,15 +100,15 @@ class RegisterAccounts( val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub) if (acc != null && acc.isWriteable()) { - val nip65Read = acc.backupNIP65RelayList?.readRelays() ?: emptyList() + val nip65Read = acc.backupNIP65RelayList?.readRelaysNorm() ?: emptyList() Log.d(tag, "Register Account ${it.npub} NIP65 Reads ${nip65Read.joinToString(", ")}") - val nip17Read = acc.backupDMRelayList?.relays() ?: emptyList() + val nip17Read = acc.backupDMRelayList?.relays() ?: emptyList() Log.d(tag, "Register Account ${it.npub} NIP17 Reads ${nip17Read.joinToString(", ")}") - val readKind3Relays = acc.backupContactList?.relays()?.mapNotNull { if (it.value.read) it.key else null } ?: emptyList() + val readKind3Relays = acc.backupContactList?.relays()?.mapNotNull { if (it.value.read) it.key else null } ?: emptyList() Log.d(tag, "Register Account ${it.npub} Kind3 Reads ${readKind3Relays.joinToString(", ")}") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt index 6268971c5..4ffa9f7b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt @@ -39,7 +39,7 @@ class DualHttpClientManager( ) { val factory = OkHttpClientFactory(keyCache) - private val defaultHttpClient: StateFlow = + val defaultHttpClient: StateFlow = combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile -> factory.buildHttpClient(proxy, mobile, userAgent) }.stateIn( @@ -48,7 +48,7 @@ class DualHttpClientManager( factory.buildHttpClient(proxyPortProvider.value, isMobileDataProvider.value, userAgent), ) - private val defaultHttpClientWithoutProxy: StateFlow = + val defaultHttpClientWithoutProxy: StateFlow = isMobileDataProvider .map { mobile -> factory.buildHttpClient(mobile, userAgent) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt index 51f8dcfdd..00ec01942 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt @@ -20,27 +20,50 @@ */ package com.vitorpamplona.amethyst.service.okhttp +import android.system.Os.socket +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder -import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilderFactory import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response class OkHttpWebSocket( - val url: String, - val forceProxy: Boolean, - val httpClient: (url: String, forceProxy: Boolean) -> OkHttpClient, + val url: NormalizedRelayUrl, + val httpClient: (url: NormalizedRelayUrl) -> OkHttpClient, val out: WebSocketListener, ) : WebSocket { private val listener = OkHttpWebsocketListener() + private var usingOkHttp: OkHttpClient? = null private var socket: okhttp3.WebSocket? = null - fun buildRequest() = Request.Builder().url(url.trim()).build() + fun buildRequest() = Request.Builder().url(url.url).build() + + override fun needsReconnect(): Boolean { + val myUsingOkHttp = usingOkHttp + if (myUsingOkHttp == null) return true + + val currentOkHttp = httpClient(url) + + val usingProxy = myUsingOkHttp.proxy + val currentProxy = currentOkHttp.proxy + + if (usingProxy != null && currentProxy != null && usingProxy != currentProxy) return true + if (usingProxy == null && currentProxy != null) return true + if (usingProxy != null && currentProxy == null) return true + + if (currentOkHttp.readTimeoutMillis != myUsingOkHttp.readTimeoutMillis) return true + if (currentOkHttp.writeTimeoutMillis != myUsingOkHttp.writeTimeoutMillis) return true + if (currentOkHttp.connectTimeoutMillis != myUsingOkHttp.connectTimeoutMillis) return true + if (currentOkHttp.callTimeoutMillis != myUsingOkHttp.callTimeoutMillis) return true + + return false + } override fun connect() { - socket = httpClient(url, forceProxy).newWebSocket(buildRequest(), listener) + usingOkHttp = httpClient(url) + socket = usingOkHttp?.newWebSocket(buildRequest(), listener) } inner class OkHttpWebsocketListener : okhttp3.WebSocketListener() { @@ -49,7 +72,7 @@ class OkHttpWebSocket( response: Response, ) = out.onOpen( response.receivedResponseAtMillis - response.sentRequestAtMillis, - response.headers.get("Sec-WebSocket-Extensions")?.contains("permessage-deflate") ?: false, + response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false, ) override fun onMessage( @@ -73,31 +96,21 @@ class OkHttpWebSocket( webSocket: okhttp3.WebSocket, t: Throwable, response: Response?, - ) = out.onFailure(t, response?.message) + ) = out.onFailure(t, response?.code, response?.message) } class Builder( - val forceProxy: Boolean, - val httpClient: (String, Boolean) -> OkHttpClient, + val httpClient: (NormalizedRelayUrl) -> OkHttpClient, ) : WebsocketBuilder { // Called when connecting. override fun build( - url: String, + url: NormalizedRelayUrl, out: WebSocketListener, - ) = OkHttpWebSocket(url, forceProxy, httpClient, out) + ) = OkHttpWebSocket(url, httpClient, out) } - class BuilderFactory( - val httpClient: (String, Boolean) -> OkHttpClient, - ) : WebsocketBuilderFactory { - override fun build( - url: String, - forceProxy: Boolean, - ) = Builder(forceProxy, httpClient) - } - - override fun cancel() { - socket?.cancel() + override fun disconnect() { + socket?.close(1000, "Normal closure") } override fun send(msg: String): Boolean = socket?.send(msg) ?: false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/ProxySettingsAnchor.kt similarity index 57% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayService.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/ProxySettingsAnchor.kt index f03cafbb2..61133f089 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/ProxySettingsAnchor.kt @@ -18,31 +18,25 @@ * 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.relays +package com.vitorpamplona.amethyst.service.okhttp -import android.content.Context -import android.util.Log -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.launch +import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation +import com.vitorpamplona.amethyst.model.torState.TorRelaySettings +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow -class RelayService( - val context: Context, -) { - val status = - callbackFlow { - Log.d("RelayService", "Starting Relay Services") - trySend(RelayServiceStatus.Connecting) +class ProxySettingsAnchor() { + val flow: MutableStateFlow> = + MutableStateFlow( + MutableStateFlow( + TorRelayEvaluation( + torSettings = TorRelaySettings(), + trustedRelayList = emptySet(), + dmRelayList = emptySet(), + ), + ), + ) - // ServiceManager - - awaitClose { - Log.d("RelayService", "Stopping Relay Services") - launch { - // ServiceManager.pauseAndLogOff() - } - trySend(RelayServiceStatus.Off) - } - }.distinctUntilChanged() + var useProxy: (NormalizedRelayUrl) -> Boolean = { flow.value.value.useTor(it) } } 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 index 168419aad..42b1d8578 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt @@ -21,11 +21,11 @@ package com.vitorpamplona.amethyst.service.relayClient import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -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.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.acessories.EventCollector +import com.vitorpamplona.quartz.nip01Core.relay.client.acessories.RelayInsertConfirmationCollector +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class CacheClientConnector( val client: NostrClient, @@ -38,8 +38,8 @@ class CacheClientConnector( val confirmationWatcher = RelayInsertConfirmationCollector(client) { eventId, relay -> - cache.markAsSeen(eventId, relay.brief) - markAsSeen(eventId, relay.brief) + cache.markAsSeen(eventId, relay.url) + markAsSeen(eventId, relay.url) } fun destroy() { @@ -49,6 +49,6 @@ class CacheClientConnector( private fun markAsSeen( eventId: HexKey, - relay: RelayBriefInfoCache.RelayBriefInfo, - ) = LocalCache.getNoteIfExists(eventId)?.addRelay(relay) + info: NormalizedRelayUrl, + ) = LocalCache.getNoteIfExists(eventId)?.addRelay(info) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayLogger.kt index 03416ce0d..6b3310725 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayLogger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayLogger.kt @@ -21,9 +21,10 @@ package com.vitorpamplona.amethyst.service.relayClient import android.util.Log -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.Relay import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient /** * Listens to NostrClient's onNotify messages from the relay @@ -36,20 +37,20 @@ class RelayLogger( } private val clientListener = - object : NostrClient.Listener { + object : IRelayClientListener { /** A new message was received */ override fun onEvent( + relay: IRelayClient, + subId: String, event: Event, - subscriptionId: String, - relay: Relay, arrivalTime: Long, afterEOSE: Boolean, ) { - Log.d(TAG, "Relay onEVENT ${relay.url} ($subscriptionId - $afterEOSE) ${event.toJson()}") + Log.d(TAG, "Relay onEVENT ${relay.url} ($subId - $afterEOSE) ${event.toJson()}") } override fun onSend( - relay: Relay, + relay: IRelayClient, msg: String, success: Boolean, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt new file mode 100644 index 000000000..4881062a9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2025 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 android.util.Log +import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager +import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager +import com.vitorpamplona.amethyst.service.okhttp.ProxySettingsAnchor +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +class RelayProxyClientConnector( + val torProxySettingsAnchor: ProxySettingsAnchor, + val okHttpClients: DualHttpClientManager, + val connManager: ConnectivityManager, + val client: NostrClient, + val scope: CoroutineScope, +) { + @OptIn(FlowPreview::class) + val relayServices = + combine( + torProxySettingsAnchor.flow, + okHttpClients.defaultHttpClient, + okHttpClients.defaultHttpClientWithoutProxy, + connManager.status, + ) { torSettings, torConnection, clearConnection, connectivity -> + torSettings.hashCode() + torConnection.hashCode() + clearConnection.hashCode() + connectivity.hashCode() + }.debounce(100).onEach { + Log.d("ManageRelayServices", "Relay Services have changed") + client.reconnect(true) + }.onStart { + Log.d("ManageRelayServices", "Resuming Relay Services") + client.connect() + }.onCompletion { + Log.d("ManageRelayServices", "Pausing Relay Services") + client.disconnect() + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.WhileSubscribed(30000), + null, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelaySpeedLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelaySpeedLogger.kt index d74be7497..a4faa10c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelaySpeedLogger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelaySpeedLogger.kt @@ -22,9 +22,12 @@ package com.vitorpamplona.amethyst.service.relayClient import android.util.Log import com.vitorpamplona.amethyst.service.relayClient.RelaySpeedLogger.Companion.TAG -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.Relay import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.utils.LargeCache import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong @@ -36,7 +39,7 @@ class KindGroup( var count: AtomicInteger = AtomicInteger(0), var memory: AtomicLong = AtomicLong(0L), val subs: LargeCache = LargeCache(), - val relays: LargeCache = LargeCache(), + val relays: LargeCache = LargeCache(), ) { companion object { const val MB: Long = 1024 @@ -45,7 +48,7 @@ class KindGroup( fun increment( mem: Long, subId: String, - relayUrl: String, + relayUrl: NormalizedRelayUrl, ) { count.incrementAndGet() memory.addAndGet(mem) @@ -74,7 +77,7 @@ class KindGroup( fun printSubs() = subs.joinToString(", ") { key, value -> if (value.get() > 0) "$key ($value)" else "" } - fun printRelays() = relays.joinToString(", ") { key, value -> if (value.get() > 0) "${key.removePrefix("wss://").removeSuffix("/")} ($value)" else "" } + fun printRelays() = relays.joinToString(", ") { key, value -> if (value.get() > 0) "${key.displayUrl()} ($value)" else "" } override fun toString() = "(${count.get()} - ${memory.get().div(MB)}kb); ${printSubs()}; ${printRelays()}" } @@ -86,7 +89,7 @@ class FrameStat { fun increment( kind: Int, subId: String, - relayUrl: String, + relayUrl: NormalizedRelayUrl, memory: Long, ) { eventCount.incrementAndGet() @@ -143,16 +146,16 @@ class RelaySpeedLogger( var current = FrameStat() private val clientListener = - object : NostrClient.Listener { + object : IRelayClientListener { /** A new message was received */ override fun onEvent( + relay: IRelayClient, + subId: String, event: Event, - subscriptionId: String, - relay: Relay, arrivalTime: Long, afterEOSE: Boolean, ) { - current.increment(event.kind, subscriptionId, relay.url, event.countMemory()) + current.increment(event.kind, subId, relay.url, event.countMemory()) } } 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 index d0d56d676..078a2cce9 100644 --- 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 @@ -23,8 +23,8 @@ 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 com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.acessories.RelayAuthenticator class ScreenAuthAccount( val account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt index f9389cb07..236d469ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt @@ -59,8 +59,4 @@ abstract class ComposeSubscriptionManager : ComposeSubscriptionManagerControl } fun allKeys() = composeSubscriptions.keys - - fun forEachSubscriber(action: (T) -> Unit) { - composeSubscriptions.keys.forEach(action) - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt index 729864eee..fb5fce646 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import android.util.Log import com.vitorpamplona.amethyst.isDebug -import com.vitorpamplona.ammolite.relays.NostrClient import com.vitorpamplona.ammolite.relays.datasources.SubscriptionController +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient abstract class BaseEoseManager( val client: NostrClient, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt index d31b5a7f7..b4ce748ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt @@ -21,10 +21,11 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.service.relays.EOSEFollowList -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlin.collections.distinctBy /** @@ -50,7 +51,7 @@ abstract class PerUniqueIdEoseManager( fun newEose( key: T, - relayUrl: String, + relayUrl: NormalizedRelayUrl, time: Long, ) { latestEOSEs.newEose(id(key), relayUrl, time) @@ -73,7 +74,7 @@ abstract class PerUniqueIdEoseManager( fun findOrCreateSubFor(key: T): Subscription { val id = id(key) - var subId = userSubscriptionMap[id] + val subId = userSubscriptionMap[id] return if (subId == null) { newSub(key).also { userSubscriptionMap[id] = it.id } } else { @@ -88,7 +89,7 @@ abstract class PerUniqueIdEoseManager( uniqueSubscribedAccounts.forEach { val mainKey = id(it) - findOrCreateSubFor(it).typedFilters = updateFilter(it, since(it))?.ifEmpty { null } + findOrCreateSubFor(it).relayBasedFilters = updateFilter(it, since(it))?.ifEmpty { null } updated.add(mainKey) } @@ -102,8 +103,8 @@ abstract class PerUniqueIdEoseManager( abstract fun updateFilter( key: T, - since: Map?, - ): List? + since: SincePerRelayMap?, + ): List? abstract fun id(key: T): String } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt index b02d63f98..62e130b8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt @@ -22,10 +22,11 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.EOSEAccount -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** * This query type creates a new relay subscription for every logged-in @@ -50,9 +51,9 @@ abstract class PerUserAndFollowListEoseManager( fun newEose( key: T, - relayUrl: String, + relay: NormalizedRelayUrl, time: Long, - ) = latestEOSEs.newEose(user(key), list(key), relayUrl, time) + ) = latestEOSEs.newEose(user(key), list(key), relay, time) open fun newSub(key: T): Subscription = orchestrator.requestNewSubscription { time, relayUrl -> @@ -71,7 +72,7 @@ abstract class PerUserAndFollowListEoseManager( fun findOrCreateSubFor(key: T): Subscription { val user = user(key) - var subId = userSubscriptionMap[user] + val subId = userSubscriptionMap[user] return if (subId == null) { newSub(key).also { userSubscriptionMap[user] = it.id } } else { @@ -86,7 +87,7 @@ abstract class PerUserAndFollowListEoseManager( uniqueSubscribedAccounts.forEach { val user = user(it) - findOrCreateSubFor(it).typedFilters = updateFilter(it, since(it))?.ifEmpty { null } + findOrCreateSubFor(it).relayBasedFilters = updateFilter(it, since(it))?.ifEmpty { null } updated.add(user) } @@ -100,8 +101,8 @@ abstract class PerUserAndFollowListEoseManager( abstract fun updateFilter( key: T, - since: Map?, - ): List? + since: SincePerRelayMap?, + ): List? abstract fun user(key: T): User diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt index 1bbe13acd..8f06ae1ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt @@ -22,10 +22,11 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlin.collections.distinctBy /** @@ -49,9 +50,9 @@ abstract class PerUserEoseManager( fun newEose( key: T, - relayUrl: String, + relay: NormalizedRelayUrl, time: Long, - ) = latestEOSEs.newEose(user(key), relayUrl, time) + ) = latestEOSEs.newEose(user(key), relay, time) open fun newSub(key: T): Subscription = orchestrator.requestNewSubscription { time, relayUrl -> @@ -70,7 +71,7 @@ abstract class PerUserEoseManager( fun findOrCreateSubFor(key: T): Subscription { val user = user(key) - var subId = userSubscriptionMap[user] + val subId = userSubscriptionMap[user] return if (subId == null) { newSub(key).also { userSubscriptionMap[user] = it.id } } else { @@ -85,7 +86,7 @@ abstract class PerUserEoseManager( uniqueSubscribedAccounts.forEach { val user = user(it) - findOrCreateSubFor(it).typedFilters = updateFilter(it, since(it))?.ifEmpty { null } + findOrCreateSubFor(it).relayBasedFilters = updateFilter(it, since(it))?.ifEmpty { null } updated.add(user) } @@ -99,8 +100,8 @@ abstract class PerUserEoseManager( abstract fun updateFilter( key: T, - since: Map?, - ): List? + since: SincePerRelayMap?, + ): List? abstract fun user(key: T): User } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt index 10c5eb3b0..992465e0c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt @@ -21,9 +21,10 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.service.relays.EOSERelayList -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlin.collections.distinctBy /** @@ -49,9 +50,9 @@ abstract class SingleSubEoseManager( fun since() = latestEOSEs.since() open fun newEose( - relayUrl: String, + relay: NormalizedRelayUrl, time: Long, - ) = latestEOSEs.newEose(relayUrl, time) + ) = latestEOSEs.newEose(relay, time) val sub = orchestrator.requestNewSubscription { time, relayUrl -> @@ -64,13 +65,13 @@ abstract class SingleSubEoseManager( override fun updateSubscriptions(keys: Set) { val uniqueSubscribedAccounts = keys.distinctBy { distinct(it) } - sub.typedFilters = updateFilter(uniqueSubscribedAccounts, since())?.ifEmpty { null } + sub.relayBasedFilters = updateFilter(uniqueSubscribedAccounts, since())?.ifEmpty { null } } abstract fun updateFilter( - key: List, - since: Map?, - ): List? + keys: List, + since: SincePerRelayMap?, + ): List? abstract fun distinct(key: T): Any } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt index f89701162..86a72b96d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import kotlin.collections.distinctBy /** @@ -44,10 +44,10 @@ abstract class SingleSubNoEoseCacheEoseManager( override fun updateSubscriptions(keys: Set) { val uniqueSubscribedAccounts = keys.distinctBy { distinct(it) } - sub.typedFilters = updateFilter(uniqueSubscribedAccounts)?.ifEmpty { null } + sub.relayBasedFilters = updateFilter(uniqueSubscribedAccounts)?.ifEmpty { null } } - abstract fun updateFilter(key: List): List? + abstract fun updateFilter(keys: List): List? abstract fun distinct(key: T): Any } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt index 921369e0c..cbcdcb88e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R @@ -28,7 +29,8 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.Notify import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import kotlinx.coroutines.flow.map @Composable fun DisplayNotifyMessages( @@ -42,14 +44,21 @@ fun DisplayNotifyMessages( accountViewModel: AccountViewModel, nav: INav, ) { - val openDialogMsg = requests.transientPaymentRequests.collectAsStateWithLifecycle(emptySet()) + val flow = + remember(accountViewModel) { + requests.transientPaymentRequests.map { + it.filter { it.relayUrl in accountViewModel.account.trustedRelays.flow.value } + } + } + + val openDialogMsg = flow.collectAsStateWithLifecycle(emptySet()) openDialogMsg.value.firstOrNull()?.let { request -> NotifyRequestDialog( title = stringRes( id = R.string.payment_required_title, - RelayUrlFormatter.displayUrl(request.relayUrl), + request.relayUrl.displayUrl(), ), textContent = request.description, accountViewModel = accountViewModel, 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 index 1caa586d5..90d392737 100644 --- 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 @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.datasources.RelayNotifier +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.acessories.RelayNotifier class NotifyCoordinator( client: NostrClient, 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 index 1d0f35e81..74aefabfa 100644 --- 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 @@ -20,7 +20,9 @@ */ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + data class NotifyRequest( - val relayUrl: String, + val relayUrl: NormalizedRelayUrl, 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 index a908f54a7..2e7fccc18 100644 --- 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -29,9 +30,9 @@ class NotifyRequestsCache { fun addPaymentRequestIfNew( description: String, - relayUrl: String, + relayUrl: NormalizedRelayUrl, ) { - addPaymentRequestIfNew(NotifyRequest(description, relayUrl)) + addPaymentRequestIfNew(NotifyRequest(relayUrl, description)) } fun addPaymentRequestIfNew(paymentRequest: NotifyRequest) { 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 index 9db47b5aa..fb0c24774 100644 --- 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 @@ -39,7 +39,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterA 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 com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import kotlinx.coroutines.CoroutineScope class RelaySubscriptionsCoordinator( @@ -63,7 +63,7 @@ class RelaySubscriptionsCoordinator( val userFinder = UserFinderFilterAssembler(client) // active when searching or tagging users. - val search = SearchFilterAssembler(cache, client, scope) + val search = SearchFilterAssembler(client, scope, cache) // active depending on the screen. val channel = ChannelFilterAssembler(client) 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 index a316a6939..cd4be9b40 100644 --- 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 @@ -25,8 +25,8 @@ import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient // This allows multiple screen to be listening to logged-in accounts. class AccountQueryState( 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 index 44b8cf699..4c201bcce 100644 --- 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 @@ -37,7 +37,7 @@ fun AccountFilterAssemblerSubscription( // even if they are tracking the same tag. val state = remember(accountViewModel) { - AccountQueryState(accountViewModel.account, accountViewModel.allAccountsSync().toSet()) + AccountQueryState(accountViewModel.account, accountViewModel.trustedAccounts.value) } KeyDataSourceSubscription(state, dataSource) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountObservers.kt index a9529ebcc..d24717964 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountObservers.kt @@ -37,7 +37,7 @@ fun observeAccountIsHiddenWord( // Subscribe in the LocalCache for changes that arrive in the device val flow = remember(account, word) { - account.flowHiddenUsers + account.hiddenUsers.flow .map { word in it.hiddenWords } .distinctUntilChanged() } @@ -53,7 +53,7 @@ fun observeAccountIsHiddenUser( // Subscribe in the LocalCache for changes that arrive in the device val flow = remember(account, user) { - account.flowHiddenUsers + account.hiddenUsers.flow .map { it.hiddenUsers.contains(user.pubkeyHex) || it.spammers.contains(user.pubkeyHex) } .distinctUntilChanged() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt index 26fcb1afb..61f9a7b7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt @@ -20,11 +20,19 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.ammolite.relays.datasources.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlin.collections.forEach class AccountMetadataEoseManager( client: NostrClient, @@ -32,15 +40,46 @@ class AccountMetadataEoseManager( ) : PerUserEoseManager(client, allKeys) { override fun user(query: AccountQueryState) = query.account.userProfile() + fun relayFlow(query: AccountQueryState) = query.account.outboxRelays.flow + override fun updateFilter( key: AccountQueryState, - since: Map?, - ): List? = - listOfNotNull( - filterAccountInfoAndListsFromKey(user(key).pubkeyHex, since), - filterFollowsAndMutesFromKey(user(key).pubkeyHex, since), - filterDraftsAndReportsFromKey(user(key).pubkeyHex, since), - filterLastPostsFromKey(user(key).pubkeyHex, since), - filterBasicAccountInfoFromKeys(key.otherAccounts.minus(key.account.userProfile().pubkeyHex).toList(), since), - ).flatten() + since: SincePerRelayMap?, + ): List = + relayFlow(key).value.flatMap { + val since = since?.get(it)?.time + listOf( + filterAccountInfoAndListsFromKey(it, user(key).pubkeyHex, since), + filterFollowsAndMutesFromKey(it, user(key).pubkeyHex, since), + filterDraftsAndReportsFromKey(it, user(key).pubkeyHex, since), + filterLastPostsFromKey(it, user(key).pubkeyHex, since), + filterBasicAccountInfoFromKeys(it, key.otherAccounts.minus(key.account.userProfile().pubkeyHex).toList(), since), + ).flatten() + } + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: AccountQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.Default) { + relayFlow(key).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 0eacefe3f..8909f2b97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -20,15 +20,13 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -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.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent @@ -36,42 +34,49 @@ import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent + +val AccountInfoAndListsFromKeyKinds = + listOf( + MetadataEvent.KIND, + ContactListEvent.KIND, + StatusEvent.KIND, + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + SearchRelayListEvent.KIND, + FileServersEvent.KIND, + BlossomServersEvent.KIND, + PrivateOutboxRelayListEvent.KIND, + ) + +val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND) +val AmethystMetadataTagMapFilter = mapOf("d" to listOf(APP_SPECIFIC_DATA_D_TAG)) fun filterAccountInfoAndListsFromKey( - pubkey: HexKey?, - since: Map?, -): List? { - if (pubkey == null || pubkey.isEmpty()) return null + relay: NormalizedRelayUrl, + pubkey: HexKey, + since: Long?, +): List { + if (pubkey.isEmpty()) return emptyList() return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - MetadataEvent.KIND, - ContactListEvent.KIND, - StatusEvent.KIND, - AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, - SearchRelayListEvent.KIND, - FileServersEvent.KIND, - BlossomServersEvent.KIND, - PrivateOutboxRelayListEvent.KIND, - ), + Filter( + kinds = AccountInfoAndListsFromKeyKinds, authors = listOf(pubkey), limit = 20, since = since, ), ), - TypedFilter( - types = COMMON_FEED_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = listOf(AppSpecificDataEvent.KIND), + Filter( + kinds = AmethystMetadataKinds, authors = listOf(pubkey), - tags = mapOf("d" to listOf(Account.APP_SPECIFIC_DATA_D_TAG)), + tags = AmethystMetadataTagMapFilter, limit = 1, since = since, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt index d25ae6974..3ab934bd2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt @@ -20,40 +20,42 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -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.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent + +val BasicAccountInfoKinds = + listOf( + MetadataEvent.KIND, + ContactListEvent.KIND, + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + SearchRelayListEvent.KIND, + FileServersEvent.KIND, + BlossomServersEvent.KIND, + ) fun filterBasicAccountInfoFromKeys( + relay: NormalizedRelayUrl, otherAccounts: List?, - since: Map?, -): List? { - if (otherAccounts == null || otherAccounts.isEmpty()) return null + since: Long?, +): List { + if (otherAccounts == null || otherAccounts.isEmpty()) return emptyList() return listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - MetadataEvent.KIND, - ContactListEvent.KIND, - AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, - SearchRelayListEvent.KIND, - FileServersEvent.KIND, - BlossomServersEvent.KIND, - ), + Filter( + kinds = BasicAccountInfoKinds, authors = otherAccounts.toList(), limit = otherAccounts.size * 8, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterDraftsAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterDraftsAndReportsFromKey.kt index b480853b3..0416ae94c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterDraftsAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterDraftsAndReportsFromKey.kt @@ -20,32 +20,34 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip37Drafts.DraftEvent import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent +val DraftsAndReportsFromKeyKinds = + listOf( + DraftEvent.KIND, + ReportEvent.KIND, + BookmarkListEvent.KIND, + ) + fun filterDraftsAndReportsFromKey( + relay: NormalizedRelayUrl, pubkey: HexKey?, - since: Map?, -): List? { - if (pubkey == null || pubkey.isEmpty()) return null + since: Long?, +): List { + if (pubkey == null || pubkey.isEmpty()) return emptyList() return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - DraftEvent.KIND, - ReportEvent.KIND, - BookmarkListEvent.KIND, - ), + Filter( + kinds = DraftsAndReportsFromKeyKinds, authors = listOf(pubkey), since = since, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt index 78eb06051..8f17ce115 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt @@ -20,12 +20,11 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent import com.vitorpamplona.quartz.nip51Lists.FollowListEvent @@ -33,27 +32,30 @@ import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent +val FollowAndMutesFromKeyKinds = + listOf( + PeopleListEvent.KIND, + FollowListEvent.KIND, + MuteListEvent.KIND, + BadgeProfilesEvent.KIND, + EmojiPackSelectionEvent.KIND, + EphemeralChatListEvent.KIND, + ChannelListEvent.KIND, + ) + fun filterFollowsAndMutesFromKey( - pubkey: HexKey?, - since: Map?, -): List? { - if (pubkey == null || pubkey.isEmpty()) return null + relay: NormalizedRelayUrl, + pubkey: HexKey, + since: Long?, +): List { + if (pubkey.isEmpty()) return emptyList() return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - PeopleListEvent.KIND, - FollowListEvent.KIND, - MuteListEvent.KIND, - BadgeProfilesEvent.KIND, - EmojiPackSelectionEvent.KIND, - EphemeralChatListEvent.KIND, - ChannelListEvent.KIND, - ), + Filter( + kinds = FollowAndMutesFromKeyKinds, authors = listOf(pubkey), limit = 100, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt index 6ff994192..00b0ec1ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt @@ -20,23 +20,23 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun filterLastPostsFromKey( - pubkey: HexKey?, - since: Map?, -): List? { - if (pubkey == null || pubkey.isEmpty()) return null + relay: NormalizedRelayUrl, + pubkey: HexKey, + since: Long?, +): List { + if (pubkey.isEmpty()) return emptyList() return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( authors = listOf(pubkey), limit = 100, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseManager.kt index 11d28a437..efa2107a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseManager.kt @@ -20,11 +20,19 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.ammolite.relays.datasources.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlin.collections.forEach class AccountNotificationsEoseManager( client: NostrClient, @@ -34,6 +42,35 @@ class AccountNotificationsEoseManager( override fun updateFilter( key: AccountQueryState, - since: Map?, - ): List? = filterNotificationsToPubkey(user(key).pubkeyHex, since) + since: SincePerRelayMap?, + ): List? = + key.account.notificationRelays.flow.value.flatMap { + filterNotificationsToPubkey(it, user(key).pubkeyHex, since?.get(it)?.time) + } + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: AccountQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.Default) { + key.account.notificationRelays.flow.collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 6f53eb51b..8a7ba5e3e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -20,15 +20,14 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent 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.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -47,62 +46,69 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +val NotificationsPerKeyKinds = + listOf( + TextNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ReportEvent.KIND, + LnZapEvent.KIND, + LnZapPaymentResponseEvent.KIND, + ChannelMessageEvent.KIND, + EphemeralChatEvent.KIND, + BadgeAwardEvent.KIND, + ) + +val NotificationsPerKeyKinds2 = + listOf( + GitReplyEvent.KIND, + GitIssueEvent.KIND, + GitPatchEvent.KIND, + HighlightEvent.KIND, + CommentEvent.KIND, + CalendarDateSlotEvent.KIND, + CalendarTimeSlotEvent.KIND, + CalendarRSVPEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + InteractiveStorySceneEvent.KIND, + ) + +val NotificationsPerKeyKinds3 = listOf(PollNoteEvent.KIND) + fun filterNotificationsToPubkey( + relay: NormalizedRelayUrl, pubkey: HexKey?, - since: Map?, -): List? { - if (pubkey == null || pubkey.isEmpty()) return null + since: Long?, +): List { + if (pubkey == null || pubkey.isEmpty()) return emptyList() return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - ReactionEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ReportEvent.KIND, - LnZapEvent.KIND, - LnZapPaymentResponseEvent.KIND, - ChannelMessageEvent.KIND, - EphemeralChatEvent.KIND, - BadgeAwardEvent.KIND, - ), + Filter( + kinds = NotificationsPerKeyKinds, tags = mapOf("p" to listOf(pubkey)), limit = 2000, since = since, ), ), - TypedFilter( - types = COMMON_FEED_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - GitReplyEvent.KIND, - GitIssueEvent.KIND, - GitPatchEvent.KIND, - HighlightEvent.KIND, - CommentEvent.KIND, - CalendarDateSlotEvent.KIND, - CalendarTimeSlotEvent.KIND, - CalendarRSVPEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - InteractiveStorySceneEvent.KIND, - ), + Filter( + kinds = NotificationsPerKeyKinds2, tags = mapOf("p" to listOf(pubkey)), limit = 400, since = since, ), ), - TypedFilter( - types = COMMON_FEED_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = listOf(PollNoteEvent.KIND), + Filter( + kinds = NotificationsPerKeyKinds3, tags = mapOf("p" to listOf(pubkey)), limit = 100, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt index 196527282..3bcc0e039 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt @@ -20,11 +20,19 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.ammolite.relays.datasources.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlin.collections.forEach class AccountGiftWrapsEoseManager( client: NostrClient, @@ -34,6 +42,40 @@ class AccountGiftWrapsEoseManager( override fun updateFilter( key: AccountQueryState, - since: Map?, - ): List? = filterGiftWrapsToPubkey(user(key).pubkeyHex, since) + since: SincePerRelayMap?, + ): List { + return key.account.dmRelays.flow.value.flatMap { relay -> + filterGiftWrapsToPubkey( + relay = relay, + pubkey = user(key).pubkeyHex, + since = since?.get(relay)?.time, + ) + } + } + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: AccountQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.Default) { + key.account.dmRelays.flow.collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/FilterGiftWrapsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/FilterGiftWrapsToPubkey.kt index 2ea5f5057..a9b0765a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/FilterGiftWrapsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/FilterGiftWrapsToPubkey.kt @@ -20,31 +20,28 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils fun filterGiftWrapsToPubkey( + relay: NormalizedRelayUrl, pubkey: HexKey?, - since: Map?, -): List? { - if (pubkey == null || pubkey.isEmpty()) return null + since: Long?, +): List { + if (pubkey == null || pubkey.isEmpty()) return emptyList() return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( kinds = listOf(GiftWrapEvent.KIND), tags = mapOf("p" to listOf(pubkey)), - since = - since?.mapValues { - EOSETime(it.value.time - TimeUtils.twoDays()) - }, + since = since?.minus(TimeUtils.twoDays()), ), ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt index 348d101fd..cc794358f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive.ChannelMetadataAndLiveActivityWatcherSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.ChannelLoaderSubAssembler -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient // This allows multiple screen to be listening to tags, even the same tag class ChannelFinderQueryState( 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 index cfd22c611..72a604220 100644 --- 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 @@ -73,10 +73,12 @@ fun observeChannelNoteAuthors( .toSet() .toImmutableList() }.onStart { - baseChannel.notes - .mapNotNull { key, value -> value.author } - .toSet() - .toImmutableList() + emit( + baseChannel.notes + .mapNotNull { key, value -> value.author } + .toSet() + .toImmutableList(), + ) }.distinctUntilChanged() .flowOn(Dispatchers.Default) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt index efd139f45..b8cb33d36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/mixChatsLive/ChannelMetadataAndLiveActivityWatcherSubAssembler.kt @@ -26,9 +26,10 @@ import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEose import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.filterChannelMetadataUpdatesById import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip53LiveActivities.filterLiveStreamUpdatesByAddress -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.utils.mapOfSet /** * This assembler observes modifications to the LiveActivity root events @@ -43,18 +44,37 @@ class ChannelMetadataAndLiveActivityWatcherSubAssembler( ) : SingleSubEoseManager(client, allKeys) { override fun updateFilter( keys: List, - since: Map?, - ): List? = - keys - .mapNotNull { key -> - if (key.channel is PublicChatChannel) { - filterChannelMetadataUpdatesById(key.channel, since) - } else if (key.channel is LiveActivitiesChannel) { - filterLiveStreamUpdatesByAddress(key.channel, since) - } else { - null + since: SincePerRelayMap?, + ): List { + val perRelayPublicChannelFilter = + mapOfSet { + keys.forEach { key -> + if (key.channel is PublicChatChannel) { + key.channel.relays().forEach { + add(it, key.channel) + } + } } - }.flatten() + } + + val perRelayLiveActivityFilter = + mapOfSet { + keys.forEach { key -> + if (key.channel is LiveActivitiesChannel) { + key.channel.relays().forEach { + add(it, key.channel) + } + } + } + } + + return perRelayPublicChannelFilter.flatMap { (relay, channels) -> + filterChannelMetadataUpdatesById(relay, channels.toList(), since?.get(relay)?.time) + } + + perRelayLiveActivityFilter.flatMap { (relay, channels) -> + filterLiveStreamUpdatesByAddress(relay, channels.toList(), since?.get(relay)?.time) + } + } override fun distinct(key: ChannelFinderQueryState) = key.channel.idHex } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt index 399338b81..83ea7d3de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28P import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter /** * This assembler observes loads missing public chats when needed. @@ -38,7 +38,7 @@ class ChannelLoaderSubAssembler( client: NostrClient, allKeys: () -> Set, ) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { - override fun updateFilter(keys: List): List? = filterMissingChannelsById(keys) + override fun updateFilter(keys: List): List? = filterMissingChannelsById(keys) override fun distinct(key: ChannelFinderQueryState) = key.channel.idHex } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelMetadataWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelMetadataWatcherSubAssembler.kt index 2b7519be9..b515fba13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelMetadataWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelMetadataWatcherSubAssembler.kt @@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28P import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class ChannelMetadataWatcherSubAssembler( client: NostrClient, @@ -33,12 +33,14 @@ class ChannelMetadataWatcherSubAssembler( ) : PerUniqueIdEoseManager(client, allKeys) { override fun updateFilter( key: ChannelFinderQueryState, - since: Map?, - ): List? = + since: SincePerRelayMap?, + ): List = if (key.channel is PublicChatChannel) { - filterChannelMetadataUpdatesById(key.channel, since) + key.channel.relays().flatMap { + filterChannelMetadataUpdatesById(it, listOf(key.channel), since?.get(it)?.time) + } } else { - null + emptyList() } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt index 861341191..26d82439e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt @@ -22,31 +22,41 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28P import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.utils.mapOfSet -fun filterMissingChannelsById(keys: List): List? { - val firstTimers = - keys.mapNotNullTo(mutableSetOf()) { - if (it.channel is PublicChatChannel && it.channel.event == null) { - it.channel.idHex - } else { - null +val filterMissingPublicChannelsByIdKinds = listOf(ChannelCreateEvent.KIND) + +fun filterMissingChannelsById(keys: List): List { + val relayPerChannel = + mapOfSet { + keys.forEach { key -> + if (key.channel is PublicChatChannel && key.channel.event == null) { + key.channel.relays().forEach { + add(it, key.channel.idHex) + } + } else { + null + } } } - if (firstTimers.isEmpty()) return null + if (relayPerChannel.isEmpty()) return emptyList() - return listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(ChannelCreateEvent.KIND), - ids = firstTimers.toList(), - ), - ), - ) + return relayPerChannel.mapNotNull { + if (it.value.isEmpty()) { + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = filterMissingPublicChannelsByIdKinds, + ids = it.value.sorted(), + ), + ) + } else { + null + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataUpdatesById.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataUpdatesById.kt index 518f44b99..067e3c9b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataUpdatesById.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataUpdatesById.kt @@ -21,23 +21,25 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +val channelMetadataKinds = listOf(ChannelMetadataEvent.KIND) + fun filterChannelMetadataUpdatesById( - channel: PublicChatChannel, - since: Map?, -): List = + relay: NormalizedRelayUrl, + channels: List, + since: Long?, +): List = listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = listOf(ChannelMetadataEvent.KIND), - tags = mapOf("e" to listOf(channel.idHex)), + Filter( + kinds = channelMetadataKinds, + tags = mapOf("e" to channels.map { it.idHex }), since = since, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/FilterLiveStreamUpdatesByAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/FilterLiveStreamUpdatesByAddress.kt index c0a77a9a3..52f87f347 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/FilterLiveStreamUpdatesByAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/FilterLiveStreamUpdatesByAddress.kt @@ -21,24 +21,25 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip53LiveActivities import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent fun filterLiveStreamUpdatesByAddress( - channel: LiveActivitiesChannel, - since: Map?, -): List { - val stream = channel.address() + relay: NormalizedRelayUrl, + channels: List, + since: Long?, +): List { + // this runs a cross product of all ds and for pubkeys, but we are assuming they are quite unique. return listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = listOf(stream.kind), - tags = mapOf("d" to listOf(stream.dTag)), - authors = listOf(stream.pubKeyHex), + Filter( + kinds = listOf(LiveActivitiesEvent.KIND), + tags = mapOf("d" to channels.map { it.address.dTag }), + authors = channels.map { it.address.pubKeyHex }, since = since, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/LiveActivityWatcherSubAssembly.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/LiveActivityWatcherSubAssembly.kt index 7a7a677ef..6aa98492b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/LiveActivityWatcherSubAssembly.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip53LiveActivities/LiveActivityWatcherSubAssembly.kt @@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip53L import com.vitorpamplona.amethyst.model.LiveActivitiesChannel import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter /** * This assembler observes modifications to the LiveActivity root events @@ -37,13 +37,16 @@ class LiveActivityWatcherSubAssembly( ) : PerUniqueIdEoseManager(client, allKeys) { override fun updateFilter( key: ChannelFinderQueryState, - since: Map?, - ): List? = - if (key.channel is LiveActivitiesChannel) { - filterLiveStreamUpdatesByAddress(key.channel, since) + since: SincePerRelayMap?, + ): List { + return if (key.channel is LiveActivitiesChannel) { + key.channel.relays().flatMap { + filterLiveStreamUpdatesByAddress(it, listOf(key.channel), since?.get(it)?.time) + } } else { - null + emptyList() } + } /** * Only one key per channel. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt index 2176a1c34..0ba866dcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.NoteEventLoaderSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers.EventWatcherSubAssembler -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient // This allows multiple screen to be listening to tags, even the same tag class EventFinderQueryState( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt index fd0fb282c..c29bdaeb4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt @@ -21,13 +21,14 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -fun filterMissingAddressables(keys: List): List? { +fun filterMissingAddressables(keys: List): List? { val missingAddressables = mutableSetOf
() keys.forEach { @@ -46,31 +47,43 @@ fun filterMissingAddressables(keys: List): List): List? { - if (missingAddressables.isEmpty()) return null +fun filterMissingAddressables(missingAddressables: Set
): List { + if (missingAddressables.isEmpty()) return emptyList() - return missingAddressables.map { aTag -> - if (aTag.kind < 25000 && aTag.dTag.isBlank()) { - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(aTag.kind), - authors = listOf(aTag.pubKeyHex), - limit = 1, - ), - ) - } else { - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(aTag.kind), - tags = mapOf("d" to listOf(aTag.dTag)), - authors = listOf(aTag.pubKeyHex), - limit = 1, - ), - ) + return missingAddressables.flatMap { aTag -> + val authorHomeRelayEventAddress = AdvertisedRelayListEvent.createAddressTag(aTag.pubKeyHex) + val authorHomeRelayEvent = (LocalCache.getAddressableNoteIfExists(authorHomeRelayEventAddress)?.event as? AdvertisedRelayListEvent) + + val authorHomeRelays = + authorHomeRelayEvent?.writeRelaysNorm()?.ifEmpty { null } + ?: LocalCache.relayHints.hintsForKey(aTag.pubKeyHex).ifEmpty { null } + ?: listOfNotNull(LocalCache.getUserIfExists(aTag.pubKeyHex)?.latestMetadataRelay) + + val relayHints = LocalCache.relayHints.hintsForAddress(aTag.toValue()) + + (authorHomeRelays + relayHints).toSet().map { + if (aTag.kind < 25000 && aTag.dTag.isBlank()) { + RelayBasedFilter( + relay = it, + filter = + Filter( + kinds = listOf(aTag.kind), + authors = listOf(aTag.pubKeyHex), + limit = 1, + ), + ) + } else { + RelayBasedFilter( + relay = it, + filter = + Filter( + kinds = listOf(aTag.kind), + tags = mapOf("d" to listOf(aTag.dTag)), + authors = listOf(aTag.pubKeyHex), + limit = 1, + ), + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt index 9fb917c1d..fb8cc330a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt @@ -21,13 +21,15 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.MapOfSetBuilder -fun filterMissingEvents(keys: List): List? { +fun filterMissingEvents(keys: List): List? { val missingEvents = mutableSetOf() keys.forEach { @@ -46,13 +48,21 @@ fun filterMissingEvents(keys: List): List? { return filterMissingEvents(missingEvents) } -fun filterMissingEvents(missingEventIds: Set): List? { - if (missingEventIds.isEmpty()) return null +fun filterMissingEvents(missingEventIds: Set): List { + if (missingEventIds.isEmpty()) return emptyList() - return listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = SincePerRelayFilter(ids = missingEventIds.sorted()), - ), - ) + val relayHints = MapOfSetBuilder() + + missingEventIds.forEach { eventId -> + LocalCache.relayHints.hintsForEvent(eventId).forEach { relayUrl -> + relayHints.add(relayUrl, eventId) + } + } + + return relayHints.build().map { + RelayBasedFilter( + relay = it.key, + filter = Filter(ids = it.value.sorted()), + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt index 2a6497169..715a6f48e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient class NoteEventLoaderSubAssembler( client: NostrClient, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt index a9ff3eb0e..483ff830b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt @@ -25,9 +25,11 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.ammolite.relays.filters.MutableTime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class EventWatcherSubAssembler( client: NostrClient, @@ -37,24 +39,24 @@ class EventWatcherSubAssembler( var latestEOSEs: EOSEAccountFast = EOSEAccountFast(10000) override fun newEose( - relayUrl: String, + relay: NormalizedRelayUrl, time: Long, ) { lastNotesOnFilter.forEach { - latestEOSEs.newEose(it, relayUrl, time) + latestEOSEs.newEose(it, relay, time) } - super.newEose(relayUrl, time) + super.newEose(relay, time) } override fun updateFilter( - keys: List, - since: Map?, - ): List? { - if (keys.isEmpty()) { + key: List, + since: SincePerRelayMap?, + ): List? { + if (key.isEmpty()) { return null } - lastNotesOnFilter = keys.map { it.note } + lastNotesOnFilter = key.map { it.note } return groupByRelayPresence(lastNotesOnFilter, latestEOSEs) .map { group -> @@ -65,7 +67,6 @@ class EventWatcherSubAssembler( listOfNotNull( filterRepliesAndReactionsToNotes(events, findMinimumEOSEs(events, latestEOSEs)), filterRepliesAndReactionsToAddresses(addressables, findMinimumEOSEs(addressables, latestEOSEs)), - filterQuotesToNotes(group, findMinimumEOSEs(group, latestEOSEs)), ).flatten() } else { emptyList() @@ -88,16 +89,16 @@ class EventWatcherSubAssembler( fun findMinimumEOSEs( notes: List, eoseCache: EOSEAccountFast, - ): Map { - val minLatestEOSEs = mutableMapOf() + ): SincePerRelayMap { + val minLatestEOSEs = mutableMapOf() notes.forEach { note -> eoseCache.since(note)?.forEach { val minEose = minLatestEOSEs[it.key] if (minEose == null) { - minLatestEOSEs.put(it.key, EOSETime(it.value.time)) - } else if (it.value.time < minEose.time) { - minEose.time = it.value.time + minLatestEOSEs.put(it.key, it.value.copy()) + } else { + minEose.updateIfOlder(it.value.time) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt index 244d613c2..f062f2982 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -21,11 +21,10 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -35,51 +34,80 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.utils.mapOfSet + +val RepliesAndReactiionsToAddressesKinds1 = + listOf( + TextNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ReportEvent.KIND, + LnZapEvent.KIND, + PollNoteEvent.KIND, + CommunityPostApprovalEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + ) + +val DeletionKindList = + listOf( + DeletionEvent.KIND, + ) + +val TextNoteKindList = listOf(TextNoteEvent.KIND) fun filterRepliesAndReactionsToAddresses( keys: List, - since: Map?, -): List? { + since: SincePerRelayMap?, +): List? { if (keys.isEmpty()) return null - val addresses = keys.mapTo(mutableSetOf()) { it.address().toValue() }.sorted() + val notesPerRelay = + mapOfSet { + keys.forEach { + it.relayUrlsForReactions().forEach { relay -> + add(relay, it.address().toValue()) + } + } + } - return listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - ReactionEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ReportEvent.KIND, - LnZapEvent.KIND, - PollNoteEvent.KIND, - CommunityPostApprovalEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - ), - tags = mapOf("a" to addresses), - since = since, - // Max amount of "replies" to download on a specific event. - limit = 1000, - ), - ), - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - DeletionEvent.KIND, - ), - tags = mapOf("a" to addresses), - since = since, - // Max amount of "replies" to download on a specific event. - limit = 10, - ), - ), - ) + return notesPerRelay.flatMap { + val since = since?.get(it.key)?.time + val sortedList = it.value.sorted() + val relay = it.key + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = RepliesAndReactiionsToAddressesKinds1, + tags = mapOf("a" to sortedList), + since = since, + // Max amount of "replies" to download on a specific event. + limit = 1000, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = DeletionKindList, + tags = mapOf("a" to sortedList), + since = since, + // Max amount of "replies" to download on a specific event. + limit = 10, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = TextNoteKindList, + tags = mapOf("q" to sortedList), + since = since, + limit = 1000, + ), + ), + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index b738ae4e0..f88122b5f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -21,12 +21,11 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -39,54 +38,81 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent +import com.vitorpamplona.quartz.utils.mapOfSet + +val RepliesAndReactionsKinds = + listOf( + TextNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ReportEvent.KIND, + LnZapEvent.KIND, + PollNoteEvent.KIND, + OtsEvent.KIND, + TextNoteModificationEvent.KIND, + GitReplyEvent.KIND, + ) + +val RepliesAndReactionsKinds2 = + listOf( + DeletionEvent.KIND, + NIP90ContentDiscoveryResponseEvent.KIND, + NIP90StatusEvent.KIND, + TorrentCommentEvent.KIND, + ) fun filterRepliesAndReactionsToNotes( events: List, - since: Map?, -): List? { + since: SincePerRelayMap?, +): List? { if (events.isEmpty()) return null - val eventIds = events.mapTo(mutableSetOf()) { it.idHex }.sorted() + val perRelayEventIds = + mapOfSet { + events.forEach { note -> + note.relayUrlsForReactions().forEach { relay -> + add(relay, note.idHex) + } + } + } - return listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - ReactionEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ReportEvent.KIND, - LnZapEvent.KIND, - PollNoteEvent.KIND, - OtsEvent.KIND, - TextNoteModificationEvent.KIND, - GitReplyEvent.KIND, - ), - tags = mapOf("e" to eventIds), - since = since, - // Max amount of "replies" to download on a specific event. - limit = 1000, - ), - ), - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - DeletionEvent.KIND, - NIP90ContentDiscoveryResponseEvent.KIND, - NIP90StatusEvent.KIND, - TorrentCommentEvent.KIND, - ), - tags = mapOf("e" to eventIds), - since = since, - limit = 100, - ), - ), - ) + return perRelayEventIds.flatMap { + val since = since?.get(it.key)?.time + val sortedList = it.value.sorted() + val relay = it.key + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = RepliesAndReactionsKinds, + tags = mapOf("e" to sortedList), + since = since, + // Max amount of "replies" to download on a specific event. + limit = 1000, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = RepliesAndReactionsKinds2, + tags = mapOf("e" to sortedList), + since = since, + limit = 100, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(TextNoteEvent.KIND), + tags = mapOf("q" to sortedList), + since = since, + limit = 1000, + ), + ), + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/FilterNWCPaymentsFromRequests.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/FilterNWCPaymentsFromRequests.kt index 6a21e0517..140ca7ca0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/FilterNWCPaymentsFromRequests.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/FilterNWCPaymentsFromRequests.kt @@ -20,31 +20,21 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent fun filterNWCPaymentsFromRequests( serviceKeys: Set, - paymentRequets: Set, + paymentRequests: Set, fromUsers: Set, - since: Map?, -): List = - listOf( - TypedFilter( - types = setOf(FeedType.WALLET_CONNECT), - filter = - SincePerRelayFilter( - kinds = listOf(LnZapPaymentResponseEvent.KIND), - authors = serviceKeys.toList(), - tags = - mapOf( - "e" to paymentRequets.toList(), - "p" to fromUsers.toList(), - ), - ), - ), +): Filter = + Filter( + kinds = listOf(LnZapPaymentResponseEvent.KIND), + authors = serviceKeys.sorted(), + tags = + mapOf( + "e" to paymentRequests.sorted(), + "p" to fromUsers.sorted(), + ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt new file mode 100644 index 000000000..fbb130e33 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2025 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.nwc + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent + +@SuppressLint("StateFlowValueCalledInComposition") +@Composable +fun NWCFinderFilterAssemblerSubscription( + note: Note, + accountViewModel: AccountViewModel, +) = NWCFinderFilterAssemblerSubscription( + note, + accountViewModel.dataSources().nwc, +) + +@Composable +fun NWCFinderFilterAssemblerSubscription( + note: Note, + dataSource: NWCPaymentFilterAssembler, +) { + // different screens get different states + // even if they are tracking the same tag. + val states = + remember(note) { + val zapPaymentRequestNote = note + (zapPaymentRequestNote.event as? LnZapPaymentRequestEvent)?.let { noteEvent -> + noteEvent.walletServicePubKey()?.let { serviceId -> + zapPaymentRequestNote.relays.map { + NWCPaymentQueryState( + fromServiceHex = serviceId, + toUserHex = noteEvent.pubKey, + replyingToHex = noteEvent.id, + relay = it, + ) + } + } + } + } + + states?.forEach { + KeyDataSourceSubscription(it, dataSource) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt index 361036420..4e4d91572 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt @@ -21,13 +21,16 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl // This allows multiple screen to be listening to tags, even the same tag class NWCPaymentQueryState( - val fromServiceHex: String, - val toUserHex: String, - val replyingToHex: String, + val fromServiceHex: HexKey, + val toUserHex: HexKey, + val replyingToHex: HexKey, + val relay: NormalizedRelayUrl, ) class NWCPaymentFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt index 24bed7d9b..98e15761e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt @@ -20,26 +20,29 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class NWCPaymentWatcherSubAssembler( client: NostrClient, allKeys: () -> Set, -) : SingleSubEoseManager(client, allKeys) { - override fun updateFilter( - keys: List, - since: Map?, - ): List? { - val fromAuthors = keys.mapTo(mutableSetOf()) { it.fromServiceHex } - val replyingToPayments = keys.mapTo(mutableSetOf()) { it.replyingToHex } - val aboutUsers = keys.mapTo(mutableSetOf()) { it.toUserHex } +) : SingleSubNoEoseCacheEoseManager(client, allKeys) { + override fun updateFilter(keys: List): List? { + if (keys.isEmpty()) return null - if (fromAuthors.isEmpty()) return null + return keys.groupBy { it.relay }.map { relayGroup -> + val fromAuthors = relayGroup.value.mapTo(mutableSetOf()) { it.fromServiceHex } + val replyingToPayments = relayGroup.value.mapTo(mutableSetOf()) { it.replyingToHex } + val aboutUsers = relayGroup.value.mapTo(mutableSetOf()) { it.toUserHex } - return filterNWCPaymentsFromRequests(fromAuthors, replyingToPayments, aboutUsers, since) + if (fromAuthors.isEmpty() || replyingToPayments.isEmpty()) return null + + RelayBasedFilter( + relay = relayGroup.key, + filter = filterNWCPaymentsFromRequests(fromAuthors, replyingToPayments, aboutUsers), + ) + } } override fun distinct(key: NWCPaymentQueryState) = key.replyingToHex diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt index 115c48be2..b7e6b8a52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt @@ -20,15 +20,17 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.UserLoaderSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserWatcherSubAssembler -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient // This allows multiple screen to be listening to tags, even the same tag class UserFinderQueryState( val user: User, + val account: Account, ) class UserFinderFilterAssembler( 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 index a171e007e..7e1450331 100644 --- 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 @@ -20,28 +20,36 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user +import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +@SuppressLint("StateFlowValueCalledInComposition") @Composable fun UserFinderFilterAssemblerSubscription( user: User, accountViewModel: AccountViewModel, -) = UserFinderFilterAssemblerSubscription(user, accountViewModel.dataSources().userFinder) +) = UserFinderFilterAssemblerSubscription( + user, + accountViewModel.account, + accountViewModel.dataSources().userFinder, +) @Composable fun UserFinderFilterAssemblerSubscription( user: User, + forAccount: Account, dataSource: UserFinderFilterAssembler, ) { // different screens get different states // even if they are tracking the same tag. val state = remember(user) { - UserFinderQueryState(user) + UserFinderQueryState(user, forAccount) } 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 index cd71650b4..9920cd424 100644 --- 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 @@ -34,8 +34,8 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.UserState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers @@ -455,7 +455,7 @@ fun observeUserIsFollowingChannel( remember(account) { account .publicChatList - .livePublicChatEventIdSet + .flowSet .mapLatest { followingChannels -> channel.idHex in followingChannels }.distinctUntilChanged() @@ -463,7 +463,7 @@ fun observeUserIsFollowingChannel( } @SuppressLint("StateFlowValueCalledInComposition") - return flow.collectAsStateWithLifecycle(channel.idHex in account.publicChatList.livePublicChatEventIdSet.value) + return flow.collectAsStateWithLifecycle(channel.idHex in account.publicChatList.flowSet.value) } @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @@ -603,7 +603,7 @@ fun observeUserStatuses( @Composable fun observeUserRelayIntoList( user: User, - relayUrl: String, + relayUrl: NormalizedRelayUrl, accountViewModel: AccountViewModel, ): State { // Subscribe in the relay for changes in the metadata of this user. @@ -656,8 +656,8 @@ fun observeUserRoomSubject( } data class RelayUsage( - val relays: List = emptyList(), - val userRelayList: List = emptyList(), + val relays: List = emptyList(), + val userRelayList: List = emptyList(), ) @OptIn(FlowPreview::class) @@ -677,7 +677,7 @@ fun observeUserRelaysUsing( val currentUserRelays = relayInfo.user.latestContactList ?.relays() - ?.map { RelayUrlFormatter.normalize(it.key) } ?: emptyList() + ?.map { it.key } ?: emptyList() RelayUsage(userRelaysBeingUsed, currentUserRelays) }.sample(1000) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt index 5e603897a..f43380b2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt @@ -20,25 +20,52 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.mapOfSet +import kotlin.collections.forEach -fun filterNewUserMetadataForKey(authors: Set): List = +val MetadataAndRelayListKinds = listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - MetadataEvent.KIND, - AdvertisedRelayListEvent.KIND, - ), - authors = authors.toList(), - ), - ), + MetadataEvent.KIND, + AdvertisedRelayListEvent.KIND, ) + +fun filterFindUserMetadataForKey(author: HexKey): List { + return LocalCache.checkGetOrCreateUser(author)?.let { + filterFindUserMetadataForKey(setOf(it)) + } ?: emptyList() +} + +fun filterFindUserMetadataForKey(authors: Set): List { + val perRelayKeys = + mapOfSet { + authors.forEach { key -> + val relays = + key.authorRelayList()?.writeRelaysNorm() + ?: LocalCache.relayHints.hintsForKey(key.pubkeyHex).ifEmpty { null } + ?: key.relaysBeingUsed.keys + + relays.forEach { + add(it, key.pubkeyHex) + } + // TODO, DESPERATION: TRY ALL THE CONNECTED RELAYS. + } + } + + return perRelayKeys.map { + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = MetadataAndRelayListKinds, + authors = it.value.sorted(), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt index 18f612c87..019847c5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt @@ -20,21 +20,42 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class UserLoaderSubAssembler( client: NostrClient, allKeys: () -> Set, ) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { - override fun updateFilter(keys: List): List? { - val firstTimers = keys.filter { it.user.latestMetadata == null }.mapTo(mutableSetOf()) { it.user.pubkeyHex } + override fun updateFilter(keys: List): List? { + val firstTimers = mutableSetOf() + + keys.forEach { + if (it.user.latestMetadata == null) { + firstTimers.add(it.user) + } else { + null + } + } + + keys.mapTo(mutableSetOf()) { it.account }.forEach { + it.kind3FollowList.flow.value.authors.forEach { + val user = LocalCache.getOrCreateUser(it) + if (user.latestMetadata == null) { + firstTimers.add(user) + } else { + null + } + } + } if (firstTimers.isEmpty()) return null - return filterNewUserMetadataForKey(firstTimers) + return filterFindUserMetadataForKey(firstTimers) } override fun distinct(key: UserFinderQueryState) = key.user diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt index 6984ac7f9..bbb5b535f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt @@ -20,25 +20,32 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip56Reports.ReportEvent -fun filterReportsToKeys( - authors: Set, - since: Map?, -): List = - listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, +val ReportKindList = listOf(ReportEvent.KIND) + +fun filterReportsToKeysFromTrusted( + targets: Set, + trustedAccounts: Map>, + since: SincePerRelayMap?, +): List { + if (targets.isEmpty() || trustedAccounts.isEmpty()) return emptyList() + val sortedTargets = targets.sorted() + return trustedAccounts.map { + RelayBasedFilter( + relay = it.key, filter = - SincePerRelayFilter( - kinds = listOf(ReportEvent.KIND), - tags = mapOf("p" to authors.toList()), - since = since, + Filter( + kinds = ReportKindList, + authors = it.value.sorted(), + tags = mapOf("p" to sortedTargets), + since = since?.get(it.key)?.time, ), - ), - ) + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt index 69e2bc365..049e3f9cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt @@ -20,36 +20,49 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.relationshipStatus.RelationshipStatusEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +val UserMetadataForKeyKinds = + listOf( + MetadataEvent.KIND, + StatusEvent.KIND, + RelationshipStatusEvent.KIND, + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + ) + fun filterUserMetadataForKey( authors: Set, - since: Map?, -): List = - listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, + since: SincePerRelayMap?, +): List { + val relays = + authors.map { + val authorHomeRelayEventAddress = AdvertisedRelayListEvent.createAddressTag(it) + val authorHomeRelayEvent = (LocalCache.getAddressableNoteIfExists(authorHomeRelayEventAddress)?.event as? AdvertisedRelayListEvent) + + authorHomeRelayEvent?.writeRelaysNorm()?.ifEmpty { null } + ?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null } + ?: listOfNotNull(LocalCache.getUserIfExists(it)?.latestMetadataRelay) + }.flatten().toSet() + + return relays.map { + RelayBasedFilter( + relay = it, filter = - SincePerRelayFilter( - kinds = - listOf( - MetadataEvent.KIND, - StatusEvent.KIND, - RelationshipStatusEvent.KIND, - AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, - ), + Filter( + kinds = UserMetadataForKeyKinds, authors = authors.toList(), - since = since, + since = since?.get(it)?.time, ), - ), - ) + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt index 5736d67af..48efdbda3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt @@ -24,43 +24,63 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.ammolite.relays.filters.MutableTime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.mapOfSet +import kotlin.collections.flatten class UserWatcherSubAssembler( client: NostrClient, allKeys: () -> Set, ) : SingleSubEoseManager(client, allKeys) { var lastUsersOnFilter: Set = emptySet() + + /** + * This assembler saves the EOSE per user key. That EOSE includes their metadata, etc + * and reports, but only from trusted accounts (follows of all logged in users). + */ var latestEOSEs: EOSEAccountFast = EOSEAccountFast(2000) override fun newEose( - relayUrl: String, + relay: NormalizedRelayUrl, time: Long, ) { lastUsersOnFilter.forEach { - latestEOSEs.newEose(it, relayUrl, time) + latestEOSEs.newEose(it, relay, time) } - super.newEose(relayUrl, time) + super.newEose(relay, time) } override fun updateFilter( keys: List, - since: Map?, - ): List? { + since: SincePerRelayMap?, + ): List? { if (keys.isEmpty()) return null - lastUsersOnFilter = keys.filter { it.user.latestMetadata != null }.map { it.user }.toSet() + lastUsersOnFilter = + keys.mapNotNullTo(mutableSetOf()) { + if (it.user.latestMetadata != null) it.user else null + } if (lastUsersOnFilter.isEmpty()) return null + val trustedAccounts = + mapOfSet { + keys.mapTo(mutableSetOf()) { it.account }.map { it.followsPerRelay.value }.forEach { + add(it) + } + } + return groupByRelayPresence(lastUsersOnFilter, latestEOSEs) .map { group -> val groupIds = group.map { it.pubkeyHex }.toSet() if (groupIds.isNotEmpty()) { val minEOSEs = findMinimumEOSEsForUsers(group, latestEOSEs) - filterUserMetadataForKey(groupIds, minEOSEs) + filterReportsToKeys(groupIds, minEOSEs) + filterUserMetadataForKey(groupIds, minEOSEs) + + filterReportsToKeysFromTrusted(groupIds, trustedAccounts, minEOSEs) } else { emptyList() } @@ -82,16 +102,16 @@ class UserWatcherSubAssembler( fun findMinimumEOSEsForUsers( users: List, eoseCache: EOSEAccountFast, - ): Map { - val minLatestEOSEs = mutableMapOf() + ): SincePerRelayMap { + val minLatestEOSEs = mutableMapOf() users.forEach { eoseCache.since(it)?.forEach { val minEose = minLatestEOSEs[it.key] if (minEose == null) { - minLatestEOSEs.put(it.key, EOSETime(it.value.time)) - } else if (it.value.time < minEose.time) { - minEose.time = it.value.time + minLatestEOSEs.put(it.key, it.value.copy()) + } else { + minEose.updateIfOlder(it.value.time) } } } 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 index 8d9f40ea7..caa17059f 100644 --- 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 @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.service.relayClient.searchCommand import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.MutableQueryState import com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies.SearchWatcherSubAssembler -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -33,14 +34,15 @@ import kotlinx.coroutines.flow.MutableStateFlow @Stable class SearchQueryState( val searchQuery: MutableStateFlow, + val account: Account, ) : MutableQueryState { override fun flow(): Flow = searchQuery } class SearchFilterAssembler( - val cache: LocalCache, client: NostrClient, scope: CoroutineScope, + val cache: LocalCache, ) : MutableComposeSubscriptionManager(scope) { val group = listOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt index 3f1155159..db63d07bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt @@ -20,43 +20,17 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies -import com.vitorpamplona.ammolite.relays.ALL_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress fun filterByAddress(address: NAddress) = - listOf( - TypedFilter( - types = ALL_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(MetadataEvent.KIND), - authors = listOfNotNull(address.author), - limit = 1, - ), + filterMissingAddressables( + setOf( + Address( + address.kind, + address.author, + address.dTag, + ), ), - if (address.kind < 25000 && address.dTag.isBlank()) { - TypedFilter( - types = ALL_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(address.kind), - authors = listOf(address.author), - limit = 5, - ), - ) - } else { - TypedFilter( - types = ALL_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(address.kind), - tags = mapOf("d" to listOf(address.dTag)), - authors = listOf(address.author), - limit = 5, - ), - ) - }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt index 8f84a487c..4be93be01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt @@ -20,21 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies -import com.vitorpamplona.ammolite.relays.ALL_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.filterFindUserMetadataForKey import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -fun filterByAuthor(pubKey: HexKey) = - listOf( - TypedFilter( - types = ALL_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(MetadataEvent.KIND), - authors = listOfNotNull(pubKey), - limit = 1, - ), - ), - ) +fun filterByAuthor(pubKey: HexKey) = filterFindUserMetadataForKey(pubKey) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt index d206c9415..950b392ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt @@ -20,18 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies -import com.vitorpamplona.ammolite.relays.ALL_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents import com.vitorpamplona.quartz.nip01Core.core.HexKey -fun filterByEvent(eventId: HexKey) = - listOf( - TypedFilter( - types = ALL_FEED_TYPES, - filter = - SincePerRelayFilter( - ids = listOfNotNull(eventId), - ), - ), - ) +fun filterByEvent(eventId: HexKey) = filterMissingEvents(setOf(eventId)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt index 3800ca745..8c3a5c095 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt @@ -20,21 +20,23 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -fun searchPeopleByName(searchString: HexKey) = - listOf( - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = - SincePerRelayFilter( - kinds = listOf(MetadataEvent.KIND), - search = searchString, - limit = 1000, - ), - ), - ) +fun searchPeopleByName( + searchString: HexKey, + relay: NormalizedRelayUrl, +) = listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(MetadataEvent.KIND), + search = searchString, + limit = 1000, + ), + ), +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt index 292f15f28..8392cdf54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt @@ -20,9 +20,6 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies -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 @@ -30,6 +27,9 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory 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.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -47,62 +47,70 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent -fun searchPostsByText(searchString: HexKey) = +val SearchPostsByTextKinds1 = listOf( - 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 = searchString, - 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 = searchString, - limit = 100, - ), - ), - TypedFilter( - types = setOf(FeedType.SEARCH), - filter = - SincePerRelayFilter( - kinds = - listOf( - InteractiveStoryPrologueEvent.KIND, - InteractiveStorySceneEvent.KIND, - FollowListEvent.KIND, - ), - search = searchString, - limit = 100, - ), - ), + TextNoteEvent.KIND, + LongTextNoteEvent.KIND, + BadgeDefinitionEvent.KIND, + PeopleListEvent.KIND, + BookmarkListEvent.KIND, + AudioHeaderEvent.KIND, + AudioTrackEvent.KIND, + PinListEvent.KIND, + PollNoteEvent.KIND, + ChannelCreateEvent.KIND, ) + +val SearchPostsByTextKinds2 = + listOf( + ChannelMetadataEvent.KIND, + ClassifiedsEvent.KIND, + CommunityDefinitionEvent.KIND, + EmojiPackEvent.KIND, + HighlightEvent.KIND, + LiveActivitiesEvent.KIND, + PollNoteEvent.KIND, + NNSEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + ) + +val SearchPostsByTextKinds3 = + listOf( + InteractiveStoryPrologueEvent.KIND, + InteractiveStorySceneEvent.KIND, + FollowListEvent.KIND, + ) + +fun searchPostsByText( + searchString: HexKey, + relay: NormalizedRelayUrl, +) = listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = SearchPostsByTextKinds1, + search = searchString, + limit = 100, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = SearchPostsByTextKinds2, + search = searchString, + limit = 100, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = SearchPostsByTextKinds3, + search = searchString, + limit = 100, + ), + ), +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchWatcherSubAssembler.kt index 2823f76c7..0b9657cef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchWatcherSubAssembler.kt @@ -23,12 +23,10 @@ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassembli import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed @@ -51,8 +49,8 @@ class SearchWatcherSubAssembler( ) : PerUniqueIdEoseManager(client, allKeys) { override fun updateFilter( key: SearchQueryState, - since: Map?, - ): List? { + since: SincePerRelayMap?, + ): List? { val mySearchString = key.searchQuery.value if (mySearchString.isBlank()) return null @@ -63,25 +61,34 @@ class SearchWatcherSubAssembler( 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.justConsume(parsed.event, null, false) - emptyList() - } + val parsed = Nip19Parser.uriToRoute(mySearchString)?.entity + if (parsed != null) { + cache.consume(parsed) - is NRelay -> emptyList() - is NAddress -> filterByAddress(parsed) - else -> emptyList() + when (parsed) { + is NSec -> filterByAuthor(parsed.toPubKeyHex()) + is NPub -> filterByAuthor(parsed.hex) + is NProfile -> filterByAuthor(parsed.hex) + is Note -> filterByEvent(parsed.hex) + is NEvent -> filterByEvent(parsed.hex) + is NEmbed -> emptyList() + is NRelay -> emptyList() + is NAddress -> filterByAddress(parsed) + else -> emptyList() + } + } else { + emptyList() } } }.getOrDefault(emptyList()) - val searchFilters = searchPeopleByName(mySearchString) + searchPostsByText(mySearchString) + val searchFilters = + key.account.searchRelayList.flow.value.flatMap { + searchPeopleByName(mySearchString, it) + } + + key.account.searchRelayList.flow.value.flatMap { + searchPostsByText(mySearchString, it) + } return directFilters + searchFilters } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt index b230de2da..037c427e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt @@ -22,22 +22,24 @@ package com.vitorpamplona.amethyst.service.relays import androidx.collection.LruCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.ammolite.relays.filters.MutableTime +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.collections.plus + +typealias SincePerRelayMap = Map class EOSERelayList { - var relayList: Map = emptyMap() + var relayList: SincePerRelayMap = emptyMap() fun addOrUpdate( - relayUrl: String, + relayUrl: NormalizedRelayUrl, time: Long, ) { val eose = relayList[relayUrl] if (eose == null) { - relayList = relayList + Pair(relayUrl, EOSETime(time)) + relayList = relayList + Pair(relayUrl, MutableTime(time)) } else { - if (time > eose.time) { - eose.update(time) - } + eose.updateIfNewer(time) } } @@ -48,9 +50,9 @@ class EOSERelayList { fun since() = relayList fun newEose( - relayUrl: String, + relay: NormalizedRelayUrl, time: Long, - ) = addOrUpdate(relayUrl, time) + ) = addOrUpdate(relay, time) } class EOSEFollowList( @@ -60,7 +62,7 @@ class EOSEFollowList( fun addOrUpdate( listCode: String, - relayUrl: String, + relayUrl: NormalizedRelayUrl, time: Long, ) { val relayList = followList[listCode] @@ -77,7 +79,7 @@ class EOSEFollowList( fun newEose( listCode: String, - relayUrl: String, + relayUrl: NormalizedRelayUrl, time: Long, ) = addOrUpdate(listCode, relayUrl, time) } @@ -90,7 +92,7 @@ class EOSEAccount( fun addOrUpdate( user: User, listCode: String, - relayUrl: String, + relayUrl: NormalizedRelayUrl, time: Long, ) { val followList = users[user] @@ -115,7 +117,7 @@ class EOSEAccount( fun newEose( user: User, listCode: String, - relayUrl: String, + relayUrl: NormalizedRelayUrl, time: Long, ) = addOrUpdate(user, listCode, relayUrl, time) } @@ -127,7 +129,7 @@ class EOSEAccountFast( fun addOrUpdate( user: T, - relayUrl: String, + relayUrl: NormalizedRelayUrl, time: Long, ) { val relayList = users[user] @@ -149,7 +151,7 @@ class EOSEAccountFast( fun newEose( user: T, - relayUrl: String, + relayUrl: NormalizedRelayUrl, time: Long, ) = addOrUpdate(user, relayUrl, time) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index 6b294ed18..6e9ab4cec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -32,11 +32,11 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult -import com.vitorpamplona.amethyst.service.uploads.nip96.randomChars import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.sha256.sha256 import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient @@ -129,7 +129,7 @@ class BlossomUploader { ): MediaUploadResult { checkNotInMainThread() - val fileName = baseFileName ?: randomChars() + val fileName = baseFileName ?: RandomInstance.randomChars(16) val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt index 706544a0d..a6dca1bda 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.nip96FileStorage.actions.PartialEvent import com.vitorpamplona.quartz.nip96FileStorage.actions.UploadResult import com.vitorpamplona.quartz.nip96FileStorage.info.ServerInfo import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.utils.RandomInstance import kotlinx.coroutines.delay import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody @@ -50,10 +51,6 @@ import okio.BufferedSink import okio.source import java.io.InputStream -val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') - -fun randomChars() = List(16) { charPool.random() }.joinToString("") - class Nip96Uploader { suspend fun upload( uri: Uri, @@ -138,7 +135,7 @@ class Nip96Uploader { ): MediaUploadResult { checkNotInMainThread() - val fileName = randomChars() + val fileName = RandomInstance.randomChars(16) val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" val client = okHttpClient(server.apiUrl) 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 916edcf22..3b5f46db8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -34,6 +34,7 @@ import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.debugState +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING @@ -145,7 +146,10 @@ class MainActivity : AppCompatActivity() { } } -fun uriToRoute(uri: String?): Route? = +fun uriToRoute( + uri: String?, + account: Account, +): Route? = if (uri?.startsWith("notifications", true) == true || uri?.startsWith("nostr:notifications", true) == true) { Route.Notification } else { @@ -153,6 +157,9 @@ fun uriToRoute(uri: String?): Route? = Route.Hashtag(uri.removePrefix("nostr:").removePrefix("hashtag?id=")) } else { val nip19 = Nip19Parser.uriToRoute(uri)?.entity + if (nip19 != null) { + LocalCache.consume(nip19) + } when (nip19) { is NPub -> Route.Profile(nip19.hex) is NProfile -> Route.Profile(nip19.hex) @@ -181,12 +188,7 @@ fun uriToRoute(uri: String?): Route? = } } - is NEmbed -> { - if (LocalCache.getNoteIfExists(nip19.event.id) == null) { - LocalCache.justConsume(nip19.event, null, false) - } - Route.EventRedirect(nip19.event.id) - } + is NEmbed -> Route.EventRedirect(nip19.event.id) else -> null } 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 33dbb3775..65d19f6d8 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 @@ -91,7 +91,9 @@ import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton import com.vitorpamplona.amethyst.ui.note.buttons.PostButton +import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest +import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.painterRes @@ -127,7 +129,7 @@ fun EditPostView( val scrollState = rememberScrollState() val scope = rememberCoroutineScope() var showRelaysDialog by remember { mutableStateOf(false) } - var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() } + var relayList = remember { accountViewModel.account.outboxRelays.flow.value.toImmutableList() } LaunchedEffect(Unit) { postViewModel.load(edit, versionLookingAt, accountViewModel) 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 f0000064b..2ede4d54b 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 @@ -45,9 +45,9 @@ import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestio import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.ammolite.relays.RelaySetupInfo import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip94FileMetadata.alt @@ -120,18 +120,18 @@ open class EditPostViewModel : ViewModel() { editedFromNote = edit } - fun sendPost(relayList: List) { + fun sendPost(relayList: List) { viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) } } - suspend fun innerSendPost(relayList: List) { + suspend fun innerSendPost(relayList: List) { if (accountViewModel == null) { cancel() return } nip95attachments.forEach { - account?.sendNip95(it.first, it.second, relayList) + account?.sendNip95(it.first, it.second, relayList.toSet()) } val notify = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index f00f3bf99..320793b38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -39,7 +39,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.ammolite.relays.RelaySetupInfo +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.joinAll @@ -82,7 +82,7 @@ open class NewMediaModel : ViewModel() { fun upload( context: Context, - relayList: List, + relayList: List, onSucess: () -> Unit, onError: (String, String) -> Unit, ) { @@ -162,7 +162,7 @@ open class NewMediaModel : ViewModel() { caption, if (sensitiveContent) "" else null, it.uploadedHash, - relayList, + relayList.toSet(), ) { continuation.resume(true) } @@ -181,7 +181,7 @@ open class NewMediaModel : ViewModel() { imageUrls, caption, if (sensitiveContent) "" else null, - relayList, + relayList.toSet(), ) { continuation.resume(true) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index 1af3b03c2..b0e2824c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -101,7 +101,7 @@ fun NewMediaView( } var showRelaysDialog by remember { mutableStateOf(false) } - var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() } + var relayList = remember { accountViewModel.account.outboxRelays.flow.value.toImmutableList() } Dialog( onDismissRequest = { onClose() }, @@ -202,7 +202,7 @@ fun ImageVideoPost( accountViewModel: AccountViewModel, ) { val nip95description = stringRes(id = R.string.upload_server_relays_nip95) - val fileServers by accountViewModel.account.liveServerList.collectAsState() + val fileServers by accountViewModel.account.serverLists.liveServerList.collectAsState() val fileServerOptions = remember(fileServers) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt index a07effd54..4b27cf41f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelaySelectionDialog.kt @@ -58,44 +58,29 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDial import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.ammolite.relays.RelaySetupInfo +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import kotlin.collections.map data class RelayList( - val relay: RelaySetupInfo, - val relayInfo: RelayBriefInfoCache.RelayBriefInfo, + val relay: NormalizedRelayUrl, val isSelected: Boolean, ) data class RelayInfoDialog( - val relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo, + val relay: NormalizedRelayUrl, val relayInfo: Nip11RelayInformation, ) -@Composable -fun RelaySelectionDialog( - preSelectedList: ImmutableList, - onClose: () -> Unit, - onPost: (list: ImmutableList) -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) = RelaySelectionDialogEasy( - preSelectedList.map { it.url }.toImmutableList(), - onClose, - onPost, - accountViewModel, - nav, -) - @OptIn(ExperimentalMaterial3Api::class) @Composable -fun RelaySelectionDialogEasy( - preSelectedList: ImmutableList, +fun RelaySelectionDialog( + preSelectedList: ImmutableList, onClose: () -> Unit, - onPost: (list: ImmutableList) -> Unit, + onPost: (list: ImmutableList) -> Unit, accountViewModel: AccountViewModel, nav: INav, ) { @@ -103,11 +88,10 @@ fun RelaySelectionDialogEasy( var relays by remember { mutableStateOf( - accountViewModel.account.connectToRelays.value.map { + accountViewModel.account.client.connectedRelayList().map { RelayList( relay = it, - relayInfo = RelayBriefInfoCache.RelayBriefInfo(it.url), - isSelected = preSelectedList.any { relayUrl -> it.url == relayUrl }, + isSelected = preSelectedList.any { relayUrl -> it == relayUrl }, ) }, ) @@ -121,7 +105,7 @@ fun RelaySelectionDialogEasy( RelayInformationDialog( onClose = { relayInfo = null }, relayInfo = it.relayInfo, - relayBriefInfo = it.relayBriefInfo, + relay = it.relay, accountViewModel = accountViewModel, nav = nav, ) @@ -147,8 +131,8 @@ fun RelaySelectionDialogEasy( actions = { SaveButton( onPost = { - val selectedRelays = relays.filter { it.isSelected } - onPost(selectedRelays.map { it.relay }.toImmutableList()) + val selectedRelays = relays.filter { it.isSelected }.map { it.relay }.toImmutableList() + onPost(selectedRelays) onClose() }, isActive = hasSelectedRelay, @@ -185,10 +169,10 @@ fun RelaySelectionDialogEasy( ) { itemsIndexed( relays, - key = { _, item -> item.relay.url }, + key = { _, item -> item.relay }, ) { index, item -> RelaySwitch( - text = item.relayInfo.displayUrl, + text = item.relay.displayUrl(), checked = item.isSelected, onClick = { relays = @@ -202,45 +186,43 @@ fun RelaySelectionDialogEasy( }, onLongPress = { accountViewModel.retrieveRelayDocument( - item.relay.url, + relay = item.relay, onInfo = { relayInfo = RelayInfoDialog( - RelayBriefInfoCache.RelayBriefInfo( - item.relay.url, - ), + item.relay, it, ) }, - onError = { url, errorCode, exceptionMessage -> + onError = { relay, errorCode, exceptionMessage -> val msg = when (errorCode) { Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> stringRes( context, R.string.relay_information_document_error_assemble_url, - url, + relay.url, exceptionMessage, ) Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> stringRes( context, R.string.relay_information_document_error_assemble_url, - url, + relay.url, exceptionMessage, ) Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> stringRes( context, R.string.relay_information_document_error_assemble_url, - url, + relay.url, exceptionMessage, ) Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> stringRes( context, R.string.relay_information_document_error_assemble_url, - url, + relay.url, exceptionMessage, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt index 2a249fd45..8172f7fea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt @@ -127,5 +127,5 @@ class BlossomServersViewModel : ViewModel() { } } - private fun obtainFileServers(): List? = account.getBlossomServersList()?.servers() + private fun obtainFileServers(): List? = account.blossomServers.fileServers.value } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt index 98cfae496..bd50c97e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt @@ -122,5 +122,5 @@ class NIP96ServersViewModel : ViewModel() { } } - private fun obtainFileServers(): List? = account.getFileServersList()?.servers() + private fun obtainFileServers(): List? = account.fileStorageServers.fileServers.value } 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 41d68ea81..7a9a8f6a3 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 @@ -164,6 +164,7 @@ fun RenderStrangeNamePreview() { @Composable fun RenderRegularPreview() { val nav = EmptyNav + val accountViewModel = mockAccountViewModel() Column(modifier = Modifier.padding(10.dp)) { RenderRegular( @@ -190,7 +191,7 @@ fun RenderRegularPreview() { ) } - is HashTagSegment -> HashTag(word, nav) + is HashTagSegment -> HashTag(word, accountViewModel, nav) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -204,6 +205,7 @@ fun RenderRegularPreview() { @Composable fun RenderRegularPreview2() { val nav = EmptyNav + val accountViewModel = mockAccountViewModel() RenderRegular( "#Amethyst v0.84.1: ncryptsec support (NIP-49)", EmptyTagList, @@ -218,7 +220,7 @@ fun RenderRegularPreview2() { is EmailSegment -> ClickableEmail(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) + is HashTagSegment -> HashTag(word, accountViewModel, nav) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -259,7 +261,7 @@ fun RenderRegularPreview3() { is EmailSegment -> ClickableEmail(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) // is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) + is HashTagSegment -> HashTag(word, accountViewModel, nav) // is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) // is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -386,7 +388,7 @@ private fun RenderWordWithoutPreview( is SecretEmoji -> Text(word.segmentText) is PhoneSegment -> ClickablePhone(word.segmentText) is BechSegment -> BechLink(word.segmentText, false, 0, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) + is HashTagSegment -> HashTag(word, accountViewModel, nav) is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) is HashIndexEventSegment -> TagLink(word, false, 0, backgroundColor, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -415,7 +417,7 @@ private fun RenderWordWithPreview( is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav) is PhoneSegment -> ClickablePhone(word.segmentText) is BechSegment -> BechLink(word.segmentText, true, quotesLeft, backgroundColor, accountViewModel, nav) - is HashTagSegment -> HashTag(word, nav) + is HashTagSegment -> HashTag(word, accountViewModel, nav) is HashIndexUserSegment -> TagLink(word, accountViewModel, nav) is HashIndexEventSegment -> TagLink(word, true, quotesLeft, backgroundColor, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) @@ -620,6 +622,7 @@ fun CoreSecretMessage( @Composable fun HashTag( segment: HashTagSegment, + accountViewModel: AccountViewModel, nav: INav, ) { val primary = MaterialTheme.colorScheme.primary diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt index cc4f5356b..59ef87311 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt @@ -73,7 +73,7 @@ fun RenderContentAsMarkdown( val onClick = remember { { link: String -> - val route = uriToRoute(link) + val route = uriToRoute(link, accountViewModel.account) if (route != null) { nav.nav(route) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt index 3243d283e..0448f0275 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt @@ -20,26 +20,22 @@ */ package com.vitorpamplona.amethyst.ui.dal -import com.vitorpamplona.amethyst.model.AROUND_ME -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS +import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNotes -import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHashes -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHashes -import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.utils.TimeUtils class FilterByListParams( - val isGlobal: Boolean, val isHiddenList: Boolean, - val isAroundMe: Boolean, - val followLists: Account.LiveFollowList?, - val hiddenLists: Account.LiveHiddenUsers, + val followLists: IFeedTopNavFilter?, + val hiddenLists: HiddenUsersState.LiveHiddenUsers, val now: Long = TimeUtils.oneMinuteFromNow(), ) { fun isNotHidden(userHex: String) = !(hiddenLists.hiddenUsers.contains(userHex) || hiddenLists.spammers.contains(userHex)) @@ -48,45 +44,39 @@ class FilterByListParams( fun isEventInList(noteEvent: Event): Boolean { if (followLists == null) return false - if (isAroundMe && followLists.geotags.isEmpty()) return false - return if (noteEvent is LiveActivitiesEvent) { - noteEvent.participantsIntersect(followLists.authors) || - noteEvent.isTaggedHashes(followLists.hashtags) || - noteEvent.isTaggedGeoHashes(followLists.geotags) || - noteEvent.isTaggedAddressableNotes(followLists.addresses) - } else if (noteEvent is CommentEvent) { - // ignore follows and checks only the root scope - noteEvent.isTaggedHashes(followLists.hashtags) || - noteEvent.isTaggedScopes(followLists.hashtagScopes) || - noteEvent.isTaggedGeoHashes(followLists.geotags) || - noteEvent.isTaggedScopes(followLists.geotagScopes) || - noteEvent.isTaggedAddressableNotes(followLists.addresses) - } else { - noteEvent.pubKey in followLists.authors || - noteEvent.isTaggedHashes(followLists.hashtags) || - noteEvent.isTaggedGeoHashes(followLists.geotags) || - noteEvent.isTaggedAddressableNotes(followLists.addresses) - } + return followLists.match(noteEvent) + } + + fun isAuthorInFollows(author: HexKey): Boolean { + if (followLists == null) return false + + return followLists.matchAuthor(author) } fun isAuthorInFollows(address: Address): Boolean { if (followLists == null) return false - return address.pubKeyHex in followLists.authors + return followLists.matchAuthor(address.pubKeyHex) } + fun isGlobal(comingFrom: List) = + followLists is GlobalTopNavFilter && + comingFrom.any { followLists.relays.value.contains(it) } + fun match( noteEvent: Event, - isGlobalRelay: Boolean = true, - ) = ((isGlobal && isGlobalRelay) || isEventInList(noteEvent)) && + comingFrom: List = emptyList(), + ) = ((isGlobal(comingFrom)) || isEventInList(noteEvent)) && (isHiddenList || isNotHidden(noteEvent.pubKey)) && isNotInTheFuture(noteEvent) - fun match(address: Address?) = - address != null && - (isGlobal || isAuthorInFollows(address)) && - (isHiddenList || isNotHidden(address.pubKeyHex)) + fun match( + address: Address?, + comingFrom: List = emptyList(), + ) = address != null && + (isGlobal(comingFrom) || isAuthorInFollows(address)) && + (isHiddenList || isNotHidden(address.pubKeyHex)) companion object { fun showHiddenKey( @@ -95,15 +85,11 @@ class FilterByListParams( ) = selectedListName == PeopleListEvent.blockListFor(userHex) || selectedListName == MuteListEvent.blockListFor(userHex) fun create( - userHex: String, - selectedListName: String, - followLists: Account.LiveFollowList?, - hiddenUsers: Account.LiveHiddenUsers, + followLists: IFeedTopNavFilter?, + hiddenUsers: HiddenUsersState.LiveHiddenUsers, ): FilterByListParams = FilterByListParams( - isGlobal = selectedListName == GLOBAL_FOLLOWS, - isHiddenList = showHiddenKey(selectedListName, userHex), - isAroundMe = selectedListName == AROUND_ME, + isHiddenList = followLists is MutedAuthorsByOutboxTopNavFilter, followLists = followLists, hiddenLists = hiddenUsers, ) 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 aee8a9f70..37cf32550 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 @@ -89,6 +89,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog import com.vitorpamplona.amethyst.ui.uriToRoute import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -129,13 +130,17 @@ fun AppNavigation( composableFromEndArgs { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ProfileScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ThreadScreen(it.id, accountViewModel, nav) } - composableFromEndArgs { HashtagScreen(it.id, accountViewModel, nav) } - composableFromEndArgs { GeoHashScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } + composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } composableFromEndArgs { CommunityScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ChatroomScreen(it.id.toString(), it.message, it.replyId, it.draftId, accountViewModel, nav) } composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } composableFromEndArgs { ChannelScreen(it.id, accountViewModel, nav) } - composableFromEndArgs { EphemeralChatScreen(RoomId(it.id, it.relayUrl), accountViewModel, nav) } + composableFromEndArgs { + RelayUrlNormalizer.normalizeOrNull(it.relayUrl)?.let { relay -> + EphemeralChatScreen(RoomId(it.id, relay), accountViewModel, nav) + } + } composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } @@ -268,7 +273,7 @@ private fun NavigateIfIntentRequested( currentIntentNextPage?.let { intentNextPage -> var actionableNextPage by remember { - mutableStateOf(uriToRoute(intentNextPage)) + mutableStateOf(uriToRoute(intentNextPage, accountViewModel.account)) } LaunchedEffect(intentNextPage) { @@ -326,7 +331,7 @@ private fun NavigateIfIntentRequested( if (!uri.isNullOrBlank()) { // navigation functions - val newPage = uriToRoute(uri) + val newPage = uriToRoute(uri, accountViewModel.account) if (newPage != null) { scope.launch { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/BottomBarRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/BottomBarRoute.kt new file mode 100644 index 000000000..dacf6b471 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/BottomBarRoute.kt @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2025 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.navigation + +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.Size23dp + +class BottomBarRoute( + val route: Route, + val icon: Int, + val contentDescriptor: Int, + val notifSize: Modifier = Modifier.size(Size23dp), + val iconSize: Modifier = Modifier.size(Size20dp), +) 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 687b012bb..d7eb4f753 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 @@ -120,8 +120,8 @@ import com.vitorpamplona.amethyst.ui.theme.bannerModifier import com.vitorpamplona.amethyst.ui.theme.drawerSpacing import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.profileContentHeaderModifier -import com.vitorpamplona.ammolite.relays.RelayPoolStatus import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayPool import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists @@ -393,7 +393,7 @@ private fun FollowingAndFollowerCounts( Row( modifier = drawerSpacing.clickable(onClick = onClick), ) { - val followingCount = baseAccountUser.liveKind3Follows.collectAsStateWithLifecycle() + val followingCount = baseAccountUser.kind3FollowList.flow.collectAsStateWithLifecycle() val followerCount by observeUserFollowerCount(baseAccountUser.userProfile(), accountViewModel) Text( @@ -528,15 +528,15 @@ fun ListContent( @Composable private fun RelayStatus(accountViewModel: AccountViewModel) { - val connectedRelaysText by accountViewModel.relayStatusFlow().collectAsStateWithLifecycle(RelayPoolStatus(0, 0)) + val connectedRelaysText by accountViewModel.relayStatusFlow().collectAsStateWithLifecycle() RenderRelayStatus(connectedRelaysText) } @Composable -private fun RenderRelayStatus(relayPool: RelayPoolStatus) { +private fun RenderRelayStatus(relayPool: RelayPool.RelayPoolStatus) { val text by - remember(relayPool) { derivedStateOf { "${relayPool.connected}/${relayPool.available}" } } + remember(relayPool) { derivedStateOf { "${relayPool.connected.size}/${relayPool.available.size}" } } val placeHolder = MaterialTheme.colorScheme.placeholderText diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/MutableSubscriptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NormalizedRelayUrlSerializer.kt similarity index 57% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/MutableSubscriptionCache.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NormalizedRelayUrlSerializer.kt index 9d78017a3..1db3eaffe 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/MutableSubscriptionCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NormalizedRelayUrlSerializer.kt @@ -18,27 +18,26 @@ * 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.amethyst.ui.navigation -class MutableSubscriptionCache : SubscriptionCache { - private var subscriptions = mapOf>() +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder - fun add( - subscriptionId: String, - filters: List = listOf(), +object NormalizedRelayUrlSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("NormalizedRelayUrl", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): NormalizedRelayUrl { + return NormalizedRelayUrl(decoder.decodeString()) + } + + override fun serialize( + encoder: Encoder, + value: NormalizedRelayUrl, ) { - subscriptions = subscriptions + Pair(subscriptionId, filters) + encoder.encodeString(value.url) } - - fun remove(subscriptionId: String) { - subscriptions = subscriptions.minus(subscriptionId) - } - - override fun isActive(subscriptionId: String): Boolean = subscriptions.contains(subscriptionId) - - override fun allSubscriptions(): Map> = subscriptions - - override fun getSubscriptionFilters(subId: String): List = subscriptions[subId] ?: emptyList() - - override fun getSubscriptionFiltersOrNull(subId: String): List? = subscriptions[subId] } 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 cc3515cb3..13e608b3c 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 @@ -171,12 +171,12 @@ fun routeToMessage( fun routeFor(note: Channel): Route = if (note is EphemeralChatChannel) { - Route.EphemeralChat(note.roomId.id, note.roomId.relayUrl) + Route.EphemeralChat(note.roomId.id, note.roomId.relayUrl.url) } else { Route.Channel(note.idHex) } -fun routeFor(roomId: RoomId): Route = Route.EphemeralChat(roomId.id, roomId.relayUrl) +fun routeFor(roomId: RoomId): Route = Route.EphemeralChat(roomId.id, roomId.relayUrl.url) fun routeFor(user: User): Route.Profile = Route.Profile(user.pubkeyHex) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index eced5ad9c..06d222e80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -20,25 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.navigation -import androidx.compose.foundation.layout.size -import androidx.compose.ui.Modifier import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController import androidx.navigation.toRoute -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.theme.Size20dp -import com.vitorpamplona.amethyst.ui.theme.Size23dp import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.serialization.Serializable -class BottomBarRoute( - val route: Route, - val icon: Int, - val contentDescriptor: Int = R.string.route, - val notifSize: Modifier = Modifier.size(Size23dp), - val iconSize: Modifier = Modifier.size(Size20dp), -) - sealed class Route { @Serializable object Home : Route() @@ -85,11 +72,11 @@ sealed class Route { ) : Route() @Serializable data class Hashtag( - val id: String, + val hashtag: String, ) : Route() @Serializable data class Geohash( - val id: String, + val geohash: String, ) : Route() @Serializable data class Community( 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 874d2597e..a1fba8d9d 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 @@ -212,7 +212,7 @@ fun LoadChannel( accountViewModel: AccountViewModel, content: @Composable (EphemeralChatChannel) -> Unit, ) { - var channel = + val channel = produceStateIfNotNull(accountViewModel.getChannelIfExists(id) as? EphemeralChatChannel, id) { value = accountViewModel.checkGetOrCreateChannel(id) as? EphemeralChatChannel } 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 9595dc576..c55ea2a92 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 @@ -977,7 +977,7 @@ fun SecondUserInfoRow( val geo = remember(noteEvent) { noteEvent.geoHashOrScope() } if (geo != null) { Spacer(StdHorzSpacer) - DisplayLocation(geo, nav) + DisplayLocation(geo, accountViewModel, nav) } val baseReward = remember(noteEvent) { noteEvent.bountyBaseReward()?.let { Reward(it) } } 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 aa8ca8285..244bd30c9 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 @@ -107,6 +107,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo 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.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.ClickableBox @@ -1156,6 +1157,12 @@ fun ObserveZapIcon( if (!wasZappedByLoggedInUser.value) { val zapsState by observeNoteZaps(baseNote, accountViewModel) + zapsState?.note?.zapPayments?.forEach { + if (it.value == null) { + NWCFinderFilterAssemblerSubscription(it.key, accountViewModel) + } + } + LaunchedEffect(key1 = zapsState) { if (zapsState?.note?.zapPayments?.isNotEmpty() == true || zapsState?.note?.zaps?.isNotEmpty() == true) { accountViewModel.calculateIfNoteWasZappedByAccount(baseNote) { newWasZapped -> @@ -1179,6 +1186,12 @@ fun ObserveZapAmountText( val zapsState by observeNoteZaps(baseNote, accountViewModel) if (zapsState?.note?.zapPayments?.isNotEmpty() == true) { + zapsState?.note?.zapPayments?.forEach { + if (it.value == null) { + NWCFinderFilterAssemblerSubscription(it.key, accountViewModel) + } + } + @Suppress("ProduceStateDoesNotAssignValue") val zapAmountTxt by produceState(initialValue = showAmount(baseNote.zapsAmount), key1 = zapsState) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt index 7aa662cf1..4fa5b6d89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt @@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -70,7 +71,7 @@ fun RelayCompose( ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Text( - relay.url.trim().removePrefix("wss://"), + text = relay.url.displayUrl(), fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt index 617c9bc0c..5317a458e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListRow.kt @@ -66,7 +66,8 @@ import com.vitorpamplona.amethyst.ui.theme.StdStartPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.relayIconModifier import com.vitorpamplona.amethyst.ui.theme.ripple24dp -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @Composable public fun RelayBadgesHorizontal( @@ -117,11 +118,11 @@ fun ChatRelayExpandButton(onClick: () -> Unit) { @OptIn(ExperimentalFoundationApi::class) @Composable fun RenderRelay( - relay: RelayBriefInfoCache.RelayBriefInfo, + relay: NormalizedRelayUrl, accountViewModel: AccountViewModel, nav: INav, ) { - val relayInfo by loadRelayInfo(relay.url, accountViewModel) + val relayInfo by loadRelayInfo(relay, accountViewModel) var openRelayDialog by remember { mutableStateOf(false) } @@ -130,7 +131,7 @@ fun RenderRelay( RelayInformationDialog( onClose = { openRelayDialog = false }, relayInfo = it, - relayBriefInfo = relay, + relay = relay, accountViewModel = accountViewModel, nav = nav, ) @@ -150,7 +151,7 @@ fun RenderRelay( }, onClick = { accountViewModel.retrieveRelayDocument( - relay.url, + relay, onInfo = { openRelayDialog = true }, @@ -170,7 +171,7 @@ fun RenderRelay( Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> R.string.relay_information_document_error_failed_with_http }, - url, + url.url, exceptionMessage ?: errorCode.toString(), ) }, @@ -184,8 +185,8 @@ fun RenderRelay( contentAlignment = Alignment.Center, ) { RenderRelayIcon( - displayUrl = relay.displayUrl, - iconUrl = relayInfo?.icon ?: relay.favIcon, + displayUrl = relay.displayUrl(), + iconUrl = relayInfo?.icon, loadProfilePicture = accountViewModel.settings.showProfilePictures.value, pingInMs = 0, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, 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 880c5ecd7..2db170fbe 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 @@ -100,11 +100,11 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -139,7 +139,7 @@ class UpdateZapAmountViewModel : ViewModel() { ?.let { TextFieldValue(it) } ?: TextFieldValue("") this.walletConnectRelay = myAccount.settings.zapPaymentRequest - ?.relayUri + ?.relayUri?.url ?.let { TextFieldValue(it) } ?: TextFieldValue("") this.walletConnectSecret = myAccount.settings.zapPaymentRequest @@ -173,11 +173,11 @@ class UpdateZapAmountViewModel : ViewModel() { null } - val relayUrl = walletConnectRelay.text.ifBlank { null }?.let { RelayUrlFormatter.normalize(it) } + val relayUrl = walletConnectRelay.text.ifBlank { null }?.let { RelayUrlNormalizer.normalizeOrNull(it) } val privKeyHex = walletConnectSecret.text.ifBlank { null }?.let { decodePrivateKeyAsHexOrNull(it) } if (pubkeyHex != null && relayUrl != null) { - Nip47WalletConnect.Nip47URI( + Nip47WalletConnect.Nip47URINorm( pubkeyHex, relayUrl, privKeyHex, @@ -224,7 +224,7 @@ class UpdateZapAmountViewModel : ViewModel() { fun updateNIP47(uri: String) { val contact = Nip47WalletConnect.parse(uri) walletConnectPubkey = TextFieldValue(contact.pubKeyHex) - walletConnectRelay = TextFieldValue(contact.relayUri) + walletConnectRelay = TextFieldValue(contact.relayUri.url) walletConnectSecret = TextFieldValue(contact.secret ?: "") } } @@ -429,8 +429,7 @@ fun UpdateZapAmountContent( ) { TextSpinner( label = stringRes(id = R.string.zap_type_explainer), - placeholder = - zapTypes.filter { it.first == accountViewModel.defaultZapType() }.first().second, + placeholder = zapTypes.firstOrNull { it.first == accountViewModel.defaultZapType() }?.second ?: zapTypes.firstOrNull()?.second ?: "", options = zapOptions, onSelect = { postViewModel.selectedZapType = zapTypes[it].first }, modifier = Modifier.weight(1f).padding(end = 5.dp), 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 ee92e08b6..c2c9eeb77 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 @@ -373,7 +373,7 @@ fun WatchUserFollows( if (accountViewModel.isLoggedUser(userHex)) { onFollowChanges(true) } else { - val state by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle() + val state by accountViewModel.account.kind3FollowList.flow.collectAsStateWithLifecycle() onFollowChanges(state.authors.contains(userHex)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt index 9d5e150b9..baf2356ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt @@ -98,7 +98,7 @@ fun ImageVideoDescription( ) { val nip95description = stringRes(id = R.string.upload_server_relays_nip95) - val fileServers by accountViewModel.account.liveServerList.collectAsState() + val fileServers by accountViewModel.account.serverLists.liveServerList.collectAsState() val fileServerOptions = remember(fileServers, includeNIP95) { 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 7bbf6fb9d..453ab6a77 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 @@ -42,7 +42,7 @@ class UserSuggestionState( ) { val invalidations = MutableStateFlow(0) val currentWord = MutableStateFlow("") - val searchDataSourceState = SearchQueryState(MutableStateFlow("")) + val searchDataSourceState = SearchQueryState(MutableStateFlow(""), accountViewModel.account) @OptIn(FlowPreview::class) val searchTerm = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt index 074168dc7..752ac98df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt @@ -50,7 +50,7 @@ fun DisplayFollowingHashtagsInPost( accountViewModel: AccountViewModel, nav: INav, ) { - val userFollowState by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle() + val userFollowState by accountViewModel.account.allFollows.flow.collectAsStateWithLifecycle() var firstTag by remember(baseNote) { mutableStateOf(null) } LaunchedEffect(key1 = userFollowState) { @@ -70,7 +70,7 @@ fun DisplayFollowingHashtagsInPost( firstTag?.let { Column(verticalArrangement = Arrangement.Center) { - Row(verticalAlignment = Alignment.CenterVertically) { DisplayTagList(it, nav) } + Row(verticalAlignment = Alignment.CenterVertically) { DisplayTagList(it, accountViewModel, nav) } } } } @@ -78,6 +78,7 @@ fun DisplayFollowingHashtagsInPost( @Composable private fun DisplayTagList( firstTag: String, + accountViewModel: AccountViewModel, nav: INav, ) { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt index 962c771be..2a6dd3af2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt @@ -31,11 +31,13 @@ import androidx.compose.ui.text.withLink import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Font14SP @Composable fun DisplayLocation( geohashStr: String, + accountViewModel: AccountViewModel, nav: INav, ) { LoadCityName(geohashStr) { cityName -> 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 6dd9e708d..40d9e0c49 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 @@ -70,7 +70,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.math.BigDecimal @@ -181,7 +180,6 @@ class AddBountyAmountViewModel : ViewModel() { newValue, bountyInner, draftTag = null, - relayList = myAccount.activeWriteRelays().toImmutableList(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt index 5d0665345..e27bdaed5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayUncitedHashtags.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.service.CachedRichTextParser import com.vitorpamplona.amethyst.ui.components.ClickableTextColor 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.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.nip01Core.core.Event @@ -43,9 +44,10 @@ import kotlinx.coroutines.launch fun DisplayUncitedHashtags( event: Event, callbackUri: String? = null, + accountViewModel: AccountViewModel, nav: INav, ) { - DisplayUncitedHashtags(event, event.content, callbackUri, nav) + DisplayUncitedHashtags(event, event.content, callbackUri, accountViewModel, nav) } @OptIn(ExperimentalLayoutApi::class) @@ -54,6 +56,7 @@ fun DisplayUncitedHashtags( event: Event, content: String, callbackUri: String? = null, + accountViewModel: AccountViewModel, nav: INav, ) { @Suppress("ProduceStateDoesNotAssignValue") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index d9fb11254..6cebce613 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -64,6 +64,7 @@ import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.hasGeohashes @@ -177,7 +178,7 @@ open class CommentPostViewModel : override val zapRaiserAmount = mutableStateOf(null) var showRelaysDialog by mutableStateOf(false) - var relayList by mutableStateOf?>(null) + var relayList by mutableStateOf?>(null) fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress() @@ -312,11 +313,11 @@ open class CommentPostViewModel : val template = createTemplate() ?: return val relayList = relayList - if (nip95attachments.isNotEmpty() && relayList != null) { + if (nip95attachments.isNotEmpty() && relayList != null && relayList.isNotEmpty()) { val usedImages = template.tags.taggedQuoteIds().toSet() nip95attachments.forEach { - if (usedImages.contains(it.second.id) == true) { - account?.sendNip95Privately(it.first, it.second, relayList) + if (usedImages.contains(it.second.id)) { + account?.sendNip95(it.first, it.second, relayList.toSet()) } } } @@ -554,17 +555,7 @@ open class CommentPostViewModel : fun reloadRelaySet() { val account = accountViewModel?.account ?: return - val nip65 = account.normalizedNIP65WriteRelayList.value - val private = account.normalizedPrivateOutBoxRelaySet.value - val local = account.settings.localRelayServers - - relayList = - if (nip65.isEmpty()) { - account.activeWriteRelays().map { it.url }.toImmutableList() - } else { - val combined: Set = (nip65 + private + local) - combined.toImmutableList() - } + relayList = account.outboxRelays.flow.value.toImmutableList() } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt index c285b862d..bfb2c15f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayExternalId.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.note.nip22Comments import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId @@ -29,11 +30,12 @@ import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId @Composable fun DisplayExternalId( externalId: ExternalId, + accountViewModel: AccountViewModel, nav: INav, ) { when (externalId) { - is GeohashId -> DisplayGeohashExternalId(externalId, nav) - is HashtagId -> DisplayHashtagExternalId(externalId, nav) + is GeohashId -> DisplayGeohashExternalId(externalId, accountViewModel, nav) + is HashtagId -> DisplayHashtagExternalId(externalId, accountViewModel, nav) else -> {} } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayGeoHashExternalId.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayGeoHashExternalId.kt index 4f6a96383..2ac4320e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayGeoHashExternalId.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayGeoHashExternalId.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.LinkInteractionListener import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withLink @@ -42,6 +43,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier @@ -50,10 +52,21 @@ import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId @Composable fun DisplayGeohashExternalId( externalId: GeohashId, + accountViewModel: AccountViewModel, nav: INav, +) { + DisplayGeohashExternalId(externalId.geohash) { + nav.nav(Route.Geohash(externalId.geohash)) + } +} + +@Composable +fun DisplayGeohashExternalId( + geohash: String, + linkInteractionListener: LinkInteractionListener, ) { Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { - LoadCityName(externalId.geohash) { cityName -> + LoadCityName(geohash) { cityName -> Icon( imageVector = Icons.Default.LocationOn, contentDescription = stringRes(id = R.string.geohash_exclusive), @@ -67,7 +80,7 @@ fun DisplayGeohashExternalId( text = buildAnnotatedString { withLink( - LinkAnnotation.Clickable("cityname") { nav.nav(Route.Geohash(externalId.geohash)) }, + LinkAnnotation.Clickable("cityname", null, linkInteractionListener), ) { append(cityName) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt index 8105c2b29..2b806eb81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.LinkInteractionListener import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withLink @@ -41,6 +42,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R 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.stringRes import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier @@ -49,7 +51,18 @@ import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId @Composable fun DisplayHashtagExternalId( externalId: HashtagId, + accountViewModel: AccountViewModel, nav: INav, +) { + DisplayHashtagExternalId(externalId.topic) { + nav.nav(Route.Hashtag(externalId.topic)) + } +} + +@Composable +fun DisplayHashtagExternalId( + topic: String, + linkInteractionListener: LinkInteractionListener, ) { Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { Icon( @@ -65,9 +78,9 @@ fun DisplayHashtagExternalId( text = buildAnnotatedString { withLink( - LinkAnnotation.Clickable("hashtag") { nav.nav(Route.Hashtag(externalId.topic)) }, + LinkAnnotation.Clickable("hashtag", null, linkInteractionListener), ) { - append(externalId.topic) + append(topic) } }, style = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 0fa2e2d66..fecbeabb1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -56,7 +56,7 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialogEasy +import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialog import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia @@ -210,10 +210,10 @@ fun GenericCommentPostScreen( }, ) { pad -> if (postViewModel.showRelaysDialog) { - RelaySelectionDialogEasy( + RelaySelectionDialog( preSelectedList = postViewModel.relayList ?: persistentListOf(), onClose = { postViewModel.showRelaysDialog = false }, - onPost = { postViewModel.relayList = it.map { it.url }.toImmutableList() }, + onPost = { postViewModel.relayList = it }, accountViewModel = accountViewModel, nav = nav, ) @@ -255,7 +255,7 @@ private fun GenericCommentPostBody( Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) { postViewModel.externalIdentity?.let { Row { - DisplayExternalId(it, nav) + DisplayExternalId(it, accountViewModel, nav) Spacer(modifier = StdVertSpacer) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt index febeb1d0d..9c36dc2e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt @@ -236,7 +236,7 @@ fun AudioHeader( if (noteEvent.hasHashtags()) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - DisplayUncitedHashtags(noteEvent, callbackUri, nav) + DisplayUncitedHashtags(noteEvent, callbackUri, accountViewModel, nav) } } } 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 c8b691ddb..55f6cb25a 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 @@ -170,6 +170,7 @@ fun LongCommunityHeader( DisplayUncitedHashtags( event = it, content = summary ?: "", + accountViewModel = accountViewModel, nav = nav, ) } @@ -360,9 +361,9 @@ fun WatchAddressableNoteFollows( accountViewModel: AccountViewModel, onFollowChanges: @Composable (Boolean) -> Unit, ) { - val state by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle() + val state by accountViewModel.account.kind3FollowList.flow.collectAsStateWithLifecycle() - onFollowChanges(state.addresses.contains(note.idHex)) + onFollowChanges(state.communities.contains(note.idHex)) } @Composable 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 e4c22a067..844d9e47c 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 @@ -185,9 +185,9 @@ private fun EmojiListOptions( CrossfadeIfEnabled(targetState = hasAddedThis, label = "EmojiListOptions", accountViewModel = accountViewModel) { if (it != true) { - AddButton(modifier = Modifier.padding(start = 3.dp)) { accountViewModel.addEmojiPack(usersEmojiList, emojiPackNote) } + AddButton(modifier = Modifier.padding(start = 3.dp)) { accountViewModel.addEmojiPack(emojiPackNote) } } else { - RemoveButton(modifier = Modifier.padding(start = 3.dp)) { accountViewModel.removeEmojiPack(usersEmojiList, emojiPackNote) } + RemoveButton(modifier = Modifier.padding(start = 3.dp)) { accountViewModel.removeEmojiPack(emojiPackNote) } } } } 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 bd509b98d..5ab551357 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 @@ -199,6 +199,7 @@ private fun RenderGitPatchEvent( event = noteEvent, content = eventContent, callbackUri = callbackUri, + accountViewModel = accountViewModel, nav = nav, ) } @@ -301,7 +302,7 @@ private fun RenderGitIssueEvent( } if (note.event?.hasHashtags() == true) { - DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, nav) + DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt index a0017ed53..0ef07dc78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt @@ -73,6 +73,6 @@ fun RenderNIP90ContentDiscoveryResponse( } if (noteEvent.hasHashtags()) { - DisplayUncitedHashtags(noteEvent, noteEvent.content, callbackUri, nav) + DisplayUncitedHashtags(noteEvent, noteEvent.content, callbackUri, accountViewModel, nav) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index 444d76d1f..7039c77b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -129,7 +129,7 @@ fun RenderPoll( } if (noteEvent.hasHashtags()) { - DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, nav) + DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt index ea568c963..d82c20c54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt @@ -124,7 +124,7 @@ fun RenderPrivateMessage( } if (noteEvent.hasHashtags()) { - DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, nav) + DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt index bdb99d55b..1f03df636 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt @@ -52,8 +52,9 @@ import com.vitorpamplona.amethyst.ui.note.RemoveRelayButton import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache import com.vitorpamplona.quartz.nip01Core.core.firstTagValueFor +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent @@ -74,7 +75,7 @@ fun DisplayRelaySet( val relays by remember(noteEvent) { mutableStateOf( - noteEvent.relays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList(), + noteEvent.relays().toImmutableList(), ) } @@ -105,14 +106,14 @@ fun DisplayNIP65RelayList( val writeRelays by remember(baseNote) { mutableStateOf( - noteEvent.writeRelays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList(), + noteEvent.writeRelaysNorm()?.toImmutableList() ?: persistentListOf(), ) } val readRelays by remember(baseNote) { mutableStateOf( - noteEvent.readRelays()?.map { RelayBriefInfoCache.RelayBriefInfo(it) }?.toImmutableList() ?: persistentListOf(), + noteEvent.readRelaysNorm()?.toImmutableList() ?: persistentListOf(), ) } @@ -147,7 +148,7 @@ fun DisplayDMRelayList( val relays by remember(baseNote) { mutableStateOf( - noteEvent.relays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList(), + noteEvent.relays().toImmutableList(), ) } @@ -173,7 +174,7 @@ fun DisplaySearchRelayList( val relays by remember(baseNote) { mutableStateOf( - noteEvent.relays().map { RelayBriefInfoCache.RelayBriefInfo(it) }.toImmutableList(), + noteEvent.relays().toImmutableList(), ) } @@ -189,7 +190,7 @@ fun DisplaySearchRelayList( @Composable fun DisplayRelaySet( - relays: ImmutableList, + relays: ImmutableList, relayListName: String, relayDescription: String?, backgroundColor: MutableState, @@ -239,7 +240,7 @@ fun DisplayRelaySet( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = relay.displayUrl, + text = relay.displayUrl(), fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -250,7 +251,7 @@ fun DisplayRelaySet( ) Column(modifier = Modifier.padding(start = 10.dp)) { - RelayOptionsAction(relay.url, accountViewModel, nav) + RelayOptionsAction(relay, accountViewModel, nav) } } } @@ -274,7 +275,7 @@ fun DisplayRelaySet( @Composable private fun RelayOptionsAction( - relay: String, + relay: NormalizedRelayUrl, accountViewModel: AccountViewModel, nav: INav, ) { @@ -282,11 +283,11 @@ private fun RelayOptionsAction( if (isCurrentlyOnTheUsersList) { AddRelayButton { - nav.nav(Route.EditRelays(relay)) + nav.nav(Route.EditRelays(relay.url)) } } else { RemoveRelayButton { - nav.nav(Route.EditRelays(relay)) + nav.nav(Route.EditRelays(relay.url)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index c9da38a2e..6f43bdb4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -163,7 +163,7 @@ fun RenderTextEvent( } if (noteEvent.hasHashtags()) { - DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, nav) + DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt index 5c008c8cb..5b47a1473 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -189,7 +189,7 @@ fun VideoDisplay( Row( Modifier.fillMaxWidth(), ) { - DisplayUncitedHashtags(event, summary, callbackUri, nav) + DisplayUncitedHashtags(event, summary, callbackUri, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 98f285f1e..43a832f8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -31,19 +31,18 @@ import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.DefaultChannels import com.vitorpamplona.amethyst.model.DefaultDMRelayList import com.vitorpamplona.amethyst.model.DefaultNIP65List +import com.vitorpamplona.amethyst.model.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.model.DefaultSearchRelayList import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow -import com.vitorpamplona.ammolite.relays.Constants import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip02FollowList.ReadWrite import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06 import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent @@ -102,8 +101,6 @@ class AccountStateViewModel : ViewModel() { private suspend fun requestLoginUI() { _accountContent.update { AccountState.LoggedOff } - - viewModelScope.launch(Dispatchers.IO) { Amethyst.instance.serviceManager.pauseAndLogOff() } } suspend fun loginAndStartUI( @@ -298,15 +295,12 @@ class AccountStateViewModel : ViewModel() { backupContactList = ContactListEvent.createFromScratch( followUsers = listOf(ContactTag(keyPair.pubKey.toHexKey(), null, null)), - relayUse = - Constants.defaultRelays.associate { - it.url to ReadWrite(it.read, it.write) - }, + relayUse = emptyMap(), signer = tempSigner, ), backupNIP65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, tempSigner), backupDMRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, tempSigner), - backupSearchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList, tempSigner), + backupSearchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList.toList(), tempSigner), backupChannelList = ChannelListEvent.create(DefaultChannels, tempSigner), torSettings = TorSettingsFlow.build(torSettings), ) @@ -319,11 +313,14 @@ class AccountStateViewModel : ViewModel() { @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { delay(2000) // waits for the new user to connect to the new relays. - accountSettings.backupUserMetadata?.let { Amethyst.instance.client.send(it) } - accountSettings.backupContactList?.let { Amethyst.instance.client.send(it) } - accountSettings.backupNIP65RelayList?.let { Amethyst.instance.client.send(it) } - accountSettings.backupDMRelayList?.let { Amethyst.instance.client.send(it) } - accountSettings.backupSearchRelayList?.let { Amethyst.instance.client.send(it) } + + val toPost = accountSettings.backupNIP65RelayList?.writeRelaysNorm()?.toSet() ?: DefaultNIP65RelaySet + + accountSettings.backupUserMetadata?.let { Amethyst.instance.client.send(it, toPost) } + accountSettings.backupContactList?.let { Amethyst.instance.client.send(it, toPost) } + accountSettings.backupNIP65RelayList?.let { Amethyst.instance.client.send(it, toPost) } + accountSettings.backupDMRelayList?.let { Amethyst.instance.client.send(it, toPost) } + accountSettings.backupSearchRelayList?.let { Amethyst.instance.client.send(it, toPost) } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt index 28f5749e2..30e4f432c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt @@ -25,11 +25,11 @@ import android.util.Log import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.ALL_FOLLOWS import com.vitorpamplona.amethyst.model.AROUND_ME import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS -import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.checkNotInMainThread @@ -75,7 +75,7 @@ class FollowListState( ) { val kind3Follow = PeopleListOutBoxFeedDefinition( - code = KIND3_FOLLOWS, + code = ALL_FOLLOWS, name = ResourceName(R.string.follow_list_kind3follows), type = CodeNameType.HARDCODED, kinds = DEFAULT_FEED_KINDS, @@ -88,7 +88,6 @@ class FollowListState( name = ResourceName(R.string.follow_list_global), type = CodeNameType.HARDCODED, kinds = DEFAULT_FEED_KINDS, - relays = account.activeGlobalRelays().toList(), ) val aroundMe = @@ -161,11 +160,11 @@ class FollowListState( @OptIn(ExperimentalCoroutinesApi::class) val liveKind3FollowsFlow: Flow> = - account.liveKind3Follows.transformLatest { + account.kind3FollowList.flow.transformLatest { checkNotInMainThread() val communities = - it.addresses.mapNotNull { + it.communities.mapNotNull { LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote -> TagFeedDefinition( "Community/${communityNote.idHex}", @@ -174,7 +173,6 @@ class FollowListState( route = Route.Community(communityNote.idHex), kinds = DEFAULT_COMMUNITY_FEEDS, aTags = listOf(communityNote.idHex), - relays = account.activeGlobalRelays().toList(), ) } } @@ -188,7 +186,6 @@ class FollowListState( route = Route.Hashtag(it), kinds = DEFAULT_FEED_KINDS, tTags = listOf(it), - relays = account.activeGlobalRelays().toList(), ) } @@ -201,7 +198,6 @@ class FollowListState( route = Route.Geohash(it), kinds = DEFAULT_FEED_KINDS, gTags = listOf(it), - relays = account.activeGlobalRelays().toList(), ) } @@ -322,7 +318,6 @@ class GlobalFeedDefinition( name: Name, type: CodeNameType, val kinds: List, - val relays: List, ) : FeedDefinition(code, name, type, null) @Immutable @@ -332,7 +327,6 @@ class TagFeedDefinition( type: CodeNameType, route: Route?, val kinds: List, - val relays: List, val pTags: List? = null, val eTags: List? = null, val aTags: List? = null, 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 6fc5e8653..b823834fe 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,10 +35,10 @@ import androidx.lifecycle.viewmodel.compose.viewModel import coil3.asDrawable import coil3.imageLoader import coil3.request.ImageRequest +import com.vitorpamplona.amethyst.AccountInfo 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 import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync import com.vitorpamplona.amethyst.isDebug @@ -54,6 +54,7 @@ import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.WarningType +import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.model.observables.CreatedAtComparator import com.vitorpamplona.amethyst.service.CashuProcessor import com.vitorpamplona.amethyst.service.CashuToken @@ -90,7 +91,7 @@ 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 +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser @@ -98,7 +99,6 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable -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 @@ -112,7 +112,6 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip37Drafts.DraftEvent import com.vitorpamplona.quartz.nip47WalletConnect.Response -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -121,11 +120,11 @@ import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.collectSuccessfulOperations import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf @@ -143,9 +142,9 @@ import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -156,25 +155,19 @@ class AccountViewModel( val app: Amethyst, ) : ViewModel(), Dao { - val account = Account(accountSettings, accountSettings.createSigner(), viewModelScope) + val account = Account(accountSettings, accountSettings.createSigner(), app.locationManager.geohashStateFlow, LocalCache, app.client, viewModelScope) val newNotesPreProcessor = PrecacheNewNotesProcessor(account, LocalCache) var firstRoute: Route? = null - // TODO: contact lists are not notes yet - // val kind3Relays: StateFlow = observeByAuthor(ContactListEvent.KIND, account.signer.pubKey) - - val normalizedKind3RelaySetFlow = + val normalizedKind3RelaySetFlow: StateFlow> = account .userProfile() .flow() .relays.stateFlow .map { contactListState -> - checkNotInMainThread() - contactListState.user.latestContactList?.relays()?.map { - RelayUrlFormatter.normalize(it.key) - } ?: emptySet() + contactListState.user.latestContactList?.relays()?.keys ?: emptySet() }.flowOn(Dispatchers.Default) .stateIn( viewModelScope, @@ -182,9 +175,6 @@ class AccountViewModel( emptySet(), ) - val dmRelays: StateFlow = observeByAuthor(ChatMessageRelayListEvent.KIND, account.signer.pubKey) - val searchRelays: StateFlow = observeByAuthor(SearchRelayListEvent.KIND, account.signer.pubKey) - val toastManager = ToastManager() val feedStates = AccountFeedContentStates(this) @@ -322,7 +312,7 @@ class AccountViewModel( fun isNoteAcceptable( note: Note, - accountChoices: Account.LiveHiddenUsers, + accountChoices: HiddenUsersState.LiveHiddenUsers, followUsers: Set, ): NoteComposeReportState { checkNotInMainThread() @@ -363,8 +353,8 @@ class AccountViewModel( fun createIsHiddenFlow(note: Note): StateFlow = noteIsHiddenFlows.get(note) ?: combineTransform( - account.flowHiddenUsers, - account.liveKind3Follows, + account.hiddenUsers.flow, + account.kind3FollowList.flow, note.flow().author(), note.flow().metadata.stateFlow, note.flow().reports.stateFlow, @@ -723,24 +713,18 @@ class AccountViewModel( viewModelScope.launch(Dispatchers.IO) { account.boost(note) } } - fun removeEmojiPack( - usersEmojiList: Note, - emojiList: Note, - ) { - viewModelScope.launch(Dispatchers.IO) { account.removeEmojiPack(usersEmojiList, emojiList) } + fun removeEmojiPack(emojiPack: Note) { + viewModelScope.launch(Dispatchers.IO) { account.removeEmojiPack(emojiPack) } } - fun addEmojiPack( - usersEmojiList: Note, - emojiPack: Note, - ) { - viewModelScope.launch(Dispatchers.IO) { account.addEmojiPack(usersEmojiList, emojiPack) } + fun addEmojiPack(emojiPack: Note) { + viewModelScope.launch(Dispatchers.IO) { account.addEmojiPack(emojiPack) } } fun addMediaToGallery( hex: String, url: String, - relay: String?, + relay: NormalizedRelayUrl?, blurhash: String?, dim: DimensionTag?, hash: String?, @@ -1032,17 +1016,15 @@ class AccountViewModel( } } - fun createRumor(template: EventTemplate) = account.signer.assembleRumor(template) - fun retrieveRelayDocument( - dirtyUrl: String, + relay: NormalizedRelayUrl, onInfo: (Nip11RelayInformation) -> Unit, - onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit, + onError: (NormalizedRelayUrl, Nip11Retriever.ErrorCode, String?) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { Nip11CachedRetriever.loadRelayInfo( - dirtyUrl, - okHttpClient = ::okHttpClientForDirty, + relay, + okHttpClient = { okHttpClientForClean(relay) }, onInfo, onError, ) @@ -1152,7 +1134,7 @@ class AccountViewModel( private suspend fun checkGetOrCreateChannel(key: HexKey): Channel? = LocalCache.checkGetOrCreateChannel(key) - suspend fun checkGetOrCreateChannel(key: RoomId): Channel? = LocalCache.checkGetOrCreateChannel(key) + suspend fun checkGetOrCreateChannel(key: RoomId): Channel? = LocalCache.getOrCreateEphemeralChannel(key) fun checkGetOrCreateChannel( key: HexKey, @@ -1284,37 +1266,7 @@ class AccountViewModel( fun setTorSettings(newTorSettings: TorSettings) = viewModelScope.launch(Dispatchers.IO) { - // Only restart relay connections if port or type changes - if (account.settings.setTorSettings(newTorSettings)) { - app.serviceManager.forceRestart() - } - } - - fun forceRestartServices() = - viewModelScope.launch(Dispatchers.IO) { - app.serviceManager.setAccountAndRestart(account) - } - - fun justStart() = - viewModelScope.launch(Dispatchers.IO) { - app.serviceManager.justStartIfItHasAccount() - } - - fun justPause() = - viewModelScope.launch(Dispatchers.IO) { - app.serviceManager.cleanObservers() - app.serviceManager.pauseForGood() - } - - fun pauseAndLogOff() = - viewModelScope.launch(Dispatchers.IO) { - app.serviceManager.cleanObservers() - app.serviceManager.pauseAndLogOff() - } - - fun changeProxyPort(port: Int) = - viewModelScope.launch(Dispatchers.IO) { - app.serviceManager.forceRestart() + account.settings.setTorSettings(newTorSettings) } class Factory( @@ -1556,7 +1508,7 @@ class AccountViewModel( fun okHttpClientForPreview(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.shouldUseTorForPreviewUrl(url)) - fun okHttpClientForDirty(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.shouldUseTorForDirty(url)) + fun okHttpClientForClean(url: NormalizedRelayUrl): OkHttpClient = app.okHttpClients.getHttpClient(account.shouldUseTorForClean(url)) fun okHttpClientForTrustedRelays(url: String): OkHttpClient = app.okHttpClients.getHttpClient(account.shouldUseTorForTrustedRelays()) @@ -1588,7 +1540,7 @@ class AccountViewModel( } fun requestDVMContentDiscovery( - dvmPublicKey: String, + dvmPublicKey: User, onReady: (event: Note) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { @@ -1708,17 +1660,27 @@ class AccountViewModel( fun relayStatusFlow() = app.client.relayStatusFlow() - fun allAccountsSync(): List = - runBlocking { - LocalPreferences.allSavedAccounts().mapNotNull { - try { - it.npub.bechToBytes().toHexKey() - } catch (e: Exception) { - if (e is CancellationException) throw e - null - } + fun convertAccounts(loggedInAccounts: List?): Set { + return loggedInAccounts?.mapNotNull { + try { + it.npub.bechToBytes().toHexKey() + } catch (e: Exception) { + if (e is CancellationException) throw e + null } - } + }?.toSet() ?: emptySet() + } + + val trustedAccounts: StateFlow> = + LocalPreferences.accountsFlow().map { loggedInAccounts -> + convertAccounts(loggedInAccounts) + }.onStart { + emit(convertAccounts(LocalPreferences.allSavedAccounts())) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptySet(), + ) val draftNoteCache = CachedDraftNotes(this) @@ -1793,9 +1755,13 @@ class AccountViewModel( val nip19: Nip19Parser.ParseReturn, ) +var mockedCache: AccountViewModel? = null + @SuppressLint("ViewModelConstructorInComposable") @Composable fun mockAccountViewModel(): AccountViewModel { + mockedCache?.let { return it } + val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() sharedPreferencesViewModel.init() @@ -1811,12 +1777,18 @@ fun mockAccountViewModel(): AccountViewModel { ), sharedPreferencesViewModel.sharedPrefs, Amethyst(), - ) + ).also { + mockedCache = it + } } +var vitorCache: AccountViewModel? = null + @SuppressLint("ViewModelConstructorInComposable") @Composable fun mockVitorAccountViewModel(): AccountViewModel { + mockedCache?.let { return it } + val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() sharedPreferencesViewModel.init() @@ -1830,5 +1802,7 @@ fun mockVitorAccountViewModel(): AccountViewModel { ), sharedPreferencesViewModel.sharedPrefs, Amethyst(), - ) + ).also { + vitorCache = it + } } 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 c40608f6c..2c6389ec2 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 @@ -54,12 +54,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.Chat import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus -import com.vitorpamplona.amethyst.ui.tor.TorType import com.vitorpamplona.quartz.nip55AndroidSigner.NostrSignerExternal import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.launch @Composable @@ -88,6 +85,9 @@ fun LoggedInPage( // Sets up Coil's Image Loader ObserveImageLoadingTor(accountViewModel) + // Sets up the use of Proxy based on this Account's settings + SetProxyDeterminator(accountViewModel) + // Loads account information + DMs and Notifications from Relays. AccountFilterAssemblerSubscription(accountViewModel) @@ -103,10 +103,8 @@ fun LoggedInPage( // 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) + // Pauses relay services when the app pauses + ManageRelayServices(accountViewModel) // Listens to Amber ListenToExternalSignerIfNeeded(accountViewModel) @@ -129,66 +127,24 @@ fun ObserveAntiSpamFilterSettings(accountViewModel: AccountViewModel) { Amethyst.instance.cache.antiSpam.active = isSpamActive } +@Composable +fun SetProxyDeterminator(accountViewModel: AccountViewModel) { + LaunchedEffect(accountViewModel) { + Amethyst.instance.torProxySettingsAnchor.flow.tryEmit(accountViewModel.account.torRelayState.flow) + } +} + @Composable fun ObserveImageLoadingTor(accountViewModel: AccountViewModel) { - LaunchedEffect(Unit) { + LaunchedEffect(accountViewModel) { Amethyst.instance.setImageLoader(accountViewModel.account::shouldUseTorForImageDownload) } } @Composable -fun ManageRelayServices( - accountViewModel: AccountViewModel, - sharedPreferencesViewModel: SharedPreferencesViewModel, -) { - LaunchedEffect( - sharedPreferencesViewModel.sharedPrefs.currentNetworkId, - sharedPreferencesViewModel.sharedPrefs.isOnMobileOrMeteredConnection, - ) { - Log.d("ManageRelayServices", "Loading/Change Network Id/State ${sharedPreferencesViewModel.sharedPrefs.currentNetworkId}, forcing start/restart of the relay services") - accountViewModel.forceRestartServices() - } - - val lifeCycleOwner = LocalLifecycleOwner.current - - val scope = rememberCoroutineScope() - var job = remember { null } - - Log.d("ManageRelayServices", "Job $job for $accountViewModel") - - DisposableEffect(key1 = accountViewModel) { - job?.cancel() - val observer = - LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> { - job?.cancel() - Log.d("ManageRelayServices", "Resuming Relay Services $accountViewModel") - job = accountViewModel.justStart() - } - Lifecycle.Event.ON_PAUSE -> { - Log.d("ManageRelayServices", "Prepare to pause Relay Services $accountViewModel") - job?.cancel() - job = - scope.launch { - delay(30000) // 30 seconds - Log.d("ManageRelayServices", "Pausing Relay Services $accountViewModel") - accountViewModel.justPause() - } - } - else -> {} - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - job?.cancel() - lifeCycleOwner.lifecycle.removeObserver(observer) - Log.d("ManageRelayServices", "Disposing Relay Services $accountViewModel") - // immediately stops upon disposal - accountViewModel.pauseAndLogOff() - } - } +fun ManageRelayServices(accountViewModel: AccountViewModel) { + val relayServices by Amethyst.instance.relayProxyClientConnector.relayServices.collectAsStateWithLifecycle() + Log.d("ManageRelayServices", "Relay Services changed $relayServices") } @Composable @@ -210,28 +166,6 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) { } } -@Composable -fun ManageTorInstance(accountViewModel: AccountViewModel) { - val torSettings by accountViewModel.account.settings.torSettings.torType - .collectAsStateWithLifecycle() - if (torSettings == TorType.INTERNAL) { - WatchTorConnection(accountViewModel) - } -} - -@Composable -fun WatchTorConnection(accountViewModel: AccountViewModel) { - val status by Amethyst.instance.torManager.status - .collectAsStateWithLifecycle() - - if (status is TorServiceStatus.Active) { - LaunchedEffect(key1 = status, key2 = accountViewModel) { - Log.d("TorService", "Tor has just finished connecting, force restart relays $accountViewModel") - accountViewModel.changeProxyPort((status as TorServiceStatus.Active).port) - } - } -} - @Composable private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) { if (accountViewModel.account.signer is NostrSignerExternal) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index e0a9b0405..e63a68a00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -233,7 +233,7 @@ fun NormalChatNote( val geo = remember(note) { note.event?.geoHashOrScope() } if (geo != null) { Spacer(StdHorzSpacer) - DisplayLocation(geo, nav) + DisplayLocation(geo, accountViewModel, nav) } val pow = remember(note) { note.event?.strongPoWOrNull() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt index 0dfaa6f9a..aef777bed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Constants import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.Nip11Retriever @@ -68,14 +69,14 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.largeProfilePictureModifier -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent -import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm @Composable fun RenderCreateChannelNote( @@ -110,11 +111,11 @@ fun RenderChannelDataPreview() { id = "bbaacc", uri = "nostr:nevent1...", channelInfo = - ChannelData( + ChannelDataNorm( "My Group", "Testing About me", "http://test.com", - listOf("wss://nostr.mom", "wss://nos.lol"), + listOf(Constants.mom, Constants.nos), ), tags = EmptyTagList, bgColor = remember { mutableStateOf(Color.Transparent) }, @@ -128,7 +129,7 @@ fun RenderChannelDataPreview() { fun RenderChannelData( id: HexKey, uri: String, - channelInfo: ChannelData, + channelInfo: ChannelDataNorm, tags: ImmutableListOfLists, bgColor: MutableState, accountViewModel: AccountViewModel, @@ -233,26 +234,21 @@ fun RenderRelayLinePreview() { @OptIn(ExperimentalFoundationApi::class) @Composable fun RenderRelayLinePublicChat( - dirtyUrl: String, + relay: NormalizedRelayUrl, accountViewModel: AccountViewModel, nav: INav, ) { @Suppress("ProduceStateDoesNotAssignValue") - val relayInfo by loadRelayInfo(dirtyUrl, accountViewModel) + val relayInfo by loadRelayInfo(relay, accountViewModel) var openRelayDialog by remember { mutableStateOf(false) } - val info = - remember(dirtyUrl) { - RelayBriefInfoCache.get(RelayUrlFormatter.normalize(dirtyUrl)) - } - if (openRelayDialog) { relayInfo?.let { RelayInformationDialog( onClose = { openRelayDialog = false }, relayInfo = it, - relayBriefInfo = info, + relay = relay, accountViewModel = accountViewModel, nav = nav, ) @@ -261,18 +257,18 @@ fun RenderRelayLinePublicChat( val clipboardManager = LocalClipboardManager.current val clickableModifier = - remember(dirtyUrl) { + remember(relay) { Modifier.combinedClickable( onLongClick = { - clipboardManager.setText(AnnotatedString(dirtyUrl)) + clipboardManager.setText(AnnotatedString(relay.url)) }, onClick = { accountViewModel.retrieveRelayDocument( - dirtyUrl, + relay = relay, onInfo = { openRelayDialog = true }, - onError = { url, errorCode, exceptionMessage -> + onError = { relay, errorCode, exceptionMessage -> accountViewModel.toastManager.toast( R.string.unable_to_download_relay_document, when (errorCode) { @@ -288,7 +284,7 @@ fun RenderRelayLinePublicChat( Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> R.string.relay_information_document_error_failed_with_http }, - url, + relay.url, exceptionMessage ?: errorCode.toString(), ) }, @@ -298,8 +294,8 @@ fun RenderRelayLinePublicChat( } RenderRelayLine( - info.displayUrl, - relayInfo?.icon ?: info.favIcon, + relay.displayUrl(), + relayInfo?.icon, clickableModifier, showPicture = accountViewModel.settings.showProfilePictures.value, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, 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 index 8d54bdbe1..2ba8587ca 100644 --- 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 @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey // This allows multiple screen to be listening to tags, even the same tag diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt index e10f139e1..04469d611 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt @@ -21,9 +21,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient class ChatroomFilterSubAssembler( client: NostrClient, @@ -31,13 +30,8 @@ class ChatroomFilterSubAssembler( ) : PerUserAndFollowListEoseManager(client, allKeys) { override fun updateFilter( key: ChatroomQueryState, - since: Map?, - ): List? = - filterNip04DMs( - key.room.users, - key.account.userProfile().pubkeyHex, - since, - ) + since: SincePerRelayMap?, + ) = filterNip04DMs(key.room.users, key.account, since) override fun user(key: ChatroomQueryState) = key.account.userProfile() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt index 3d6790460..e36eb8247 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt @@ -20,41 +20,75 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import kotlin.collections.ifEmpty import kotlin.collections.toList fun filterNip04DMs( group: Set?, - user: HexKey?, - since: Map?, -): List? { - if (group == null || group.isEmpty() || user == null || user.isEmpty()) return null + account: Account?, + since: SincePerRelayMap?, +): List? { + if (group == null || group.isEmpty() || account == null) return null - return listOf( - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), + val userOutboxRelays = account.outboxRelays.flow.value + val userInboxRelays = account.dmRelays.flow.value + + val groupOutboxRelays = mutableSetOf() + val groupInboxRelays = mutableSetOf() + + group.forEach { + val authorHomeRelayEventAddress = AdvertisedRelayListEvent.createAddressTag(it) + val authorHomeRelayEvent = (LocalCache.getAddressableNoteIfExists(authorHomeRelayEventAddress)?.event as? AdvertisedRelayListEvent) + + val outbox = + authorHomeRelayEvent?.writeRelaysNorm()?.ifEmpty { null } + ?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null } + ?: listOfNotNull(LocalCache.getUserIfExists(it)?.latestMetadataRelay) + + groupOutboxRelays.addAll(outbox) + + val inbox = + authorHomeRelayEvent?.readRelaysNorm()?.ifEmpty { null } + ?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null } + ?: listOfNotNull(LocalCache.getUserIfExists(it)?.latestMetadataRelay) + + groupInboxRelays.addAll(inbox) + } + + val toMeRelays = (userInboxRelays + groupOutboxRelays) + val fromMeRelays = (userOutboxRelays + groupInboxRelays) + + return toMeRelays.map { + RelayBasedFilter( + relay = it, filter = - SincePerRelayFilter( + Filter( kinds = listOf(PrivateDmEvent.KIND), authors = group.toList(), - tags = mapOf("p" to listOf(user)), - since = since, + tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), + since = since?.get(it)?.time, ), - ), - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = - SincePerRelayFilter( - kinds = listOf(PrivateDmEvent.KIND), - authors = listOf(user), - tags = mapOf("p" to group.toList()), - since = since, - ), - ), - ) + ) + } + + fromMeRelays.map { + RelayBasedFilter( + relay = it, + filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + tags = mapOf("p" to group.toList()), + since = since?.get(it)?.time, + ), + ) + } } 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 e20c597ec..196c7dc45 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 @@ -622,7 +622,7 @@ class ChatNewMessageViewModel : toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item) updateRoomFromUsersInput() - val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(item.pubkeyHex))?.event as? AdvertisedRelayListEvent)?.readRelays() + val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(item.pubkeyHex))?.event as? AdvertisedRelayListEvent)?.readRelaysNorm() nip17 = relayList != null } 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 index 79790d8ac..b0faabfc6 100644 --- 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 @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.ChannelFromUserFilterSubAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.ChannelPublicFilterSubAssembler -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient // This allows multiple screen to be listening to tags, even the same tag class ChannelQueryState( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelFromUserFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelFromUserFilterSubAssembler.kt index 1d0374ea2..d9f72f4dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelFromUserFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelFromUserFilterSubAssembler.kt @@ -24,10 +24,10 @@ import com.vitorpamplona.amethyst.model.EphemeralChatChannel import com.vitorpamplona.amethyst.model.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class ChannelFromUserFilterSubAssembler( client: NostrClient, @@ -35,8 +35,8 @@ class ChannelFromUserFilterSubAssembler( ) : PerUserAndFollowListEoseManager(client, allKeys) { override fun updateFilter( key: ChannelQueryState, - since: Map?, - ): List? = + since: SincePerRelayMap?, + ): List? = when (val channel = key.channel) { is EphemeralChatChannel -> filterMyMessagesToEphemeralChat(channel, userHex(key), since) is PublicChatChannel -> filterMyMessagesToPublicChat(channel, user(key).pubkeyHex, since) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt index 3c3a38263..31455be31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt @@ -24,10 +24,10 @@ import com.vitorpamplona.amethyst.model.EphemeralChatChannel import com.vitorpamplona.amethyst.model.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class ChannelPublicFilterSubAssembler( client: NostrClient, @@ -35,8 +35,8 @@ class ChannelPublicFilterSubAssembler( ) : PerUniqueIdEoseManager(client, allKeys) { override fun updateFilter( key: ChannelQueryState, - since: Map?, - ): List? = + since: SincePerRelayMap?, + ): List? = when (val channel = key.channel) { is EphemeralChatChannel -> filterMessagesToEphemeralChat(channel, since) is PublicChatChannel -> filterMessagesToPublicChat(channel, since) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToEphemeralChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToEphemeralChat.kt index 849650b0a..fc208afdf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToEphemeralChat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToEphemeralChat.kt @@ -21,22 +21,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies import com.vitorpamplona.amethyst.model.EphemeralChatChannel -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter fun filterMessagesToEphemeralChat( channel: EphemeralChatChannel, - since: Map?, -): List? = - listOf( - TypedFilter( - types = - setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL), + since: SincePerRelayMap?, +): List = + channel.relays().toSet().map { + RelayBasedFilter( + relay = it, filter = - SincePerRelayFilter( + Filter( kinds = listOf(EphemeralChatEvent.KIND), tags = if (channel.roomId.id.isBlank()) { @@ -45,7 +43,7 @@ fun filterMessagesToEphemeralChat( mapOf("d" to listOfNotNull(channel.roomId.id)) }, limit = 200, - since = since, + since = since?.get(it)?.time, ), - ), - ) + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt index c3177484c..f11426e9e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt @@ -21,25 +21,24 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent fun filterMessagesToLiveActivities( channel: LiveActivitiesChannel, - since: Map?, -): List? = - listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), + since: SincePerRelayMap?, +): List = + channel.relays().toSet().map { + RelayBasedFilter( + relay = it, filter = - SincePerRelayFilter( + Filter( kinds = listOf(LiveActivitiesChatMessageEvent.KIND), tags = mapOf("a" to listOfNotNull(channel.idHex)), limit = 200, - since = since, + since = since?.get(it)?.time, ), - ), - ) + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToPublicChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToPublicChat.kt index 046b7a026..61c5bcf30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToPublicChat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToPublicChat.kt @@ -21,25 +21,24 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent fun filterMessagesToPublicChat( channel: PublicChatChannel, - since: Map?, -): List? = - listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), + since: SincePerRelayMap?, +): List? = + channel.relays().toSet().map { + RelayBasedFilter( + relay = it, filter = - SincePerRelayFilter( + Filter( kinds = listOf(ChannelMessageEvent.KIND), tags = mapOf("e" to listOfNotNull(channel.idHex)), limit = 200, - since = since, + since = since?.get(it)?.time, ), - ), - ) + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToEphemeralChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToEphemeralChat.kt index 4e290248d..0727ccda9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToEphemeralChat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToEphemeralChat.kt @@ -21,24 +21,22 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies import com.vitorpamplona.amethyst.model.EphemeralChatChannel -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter fun filterMyMessagesToEphemeralChat( channel: EphemeralChatChannel, pubKey: HexKey, - since: Map?, -): List? = - listOf( - TypedFilter( - types = - setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL), + since: SincePerRelayMap?, +): List = + channel.relays().toSet().map { + RelayBasedFilter( + relay = it, filter = - SincePerRelayFilter( + Filter( kinds = listOf(EphemeralChatEvent.KIND), tags = if (channel.roomId.id.isBlank()) { @@ -48,7 +46,7 @@ fun filterMyMessagesToEphemeralChat( }, authors = listOf(pubKey), limit = 50, - since = since, + since = since?.get(it)?.time, ), - ), - ) + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt index 40887b40e..509b11334 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt @@ -21,28 +21,27 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent fun filterMyMessagesToLiveActivities( channel: LiveActivitiesChannel, pubKey: HexKey, - since: Map?, -): List? = - listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH), + since: SincePerRelayMap?, +): List? = + channel.relays().toSet().map { + RelayBasedFilter( + relay = it, filter = - SincePerRelayFilter( + Filter( kinds = listOf(LiveActivitiesChatMessageEvent.KIND), tags = mapOf("a" to listOfNotNull(channel.idHex)), authors = listOf(pubKey), limit = 50, - since = since, + since = since?.get(it)?.time, ), - ), - ) + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToPublicChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToPublicChat.kt index 25f7660c0..681e36aaf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToPublicChat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToPublicChat.kt @@ -21,28 +21,27 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent fun filterMyMessagesToPublicChat( channel: PublicChatChannel, pubKey: HexKey, - since: Map?, -): List? = - listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH), + since: SincePerRelayMap?, +): List = + channel.relays().toSet().map { + RelayBasedFilter( + relay = it, filter = - SincePerRelayFilter( + Filter( kinds = listOf(ChannelMessageEvent.KIND), tags = mapOf("e" to listOfNotNull(channel.idHex)), authors = listOf(pubKey), limit = 50, - since = since, + since = since?.get(it)?.time, ), - ), - ) + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt index 17c205572..6550a5f6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt @@ -50,28 +50,24 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemC import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier import com.vitorpamplona.amethyst.ui.theme.Size35dp -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter @Composable fun loadRelayInfo( - relayUrl: String, + relay: NormalizedRelayUrl, accountViewModel: AccountViewModel, ): State { @Suppress("ProduceStateDoesNotAssignValue") val relayInfo = produceStateIfNotNull( - Nip11CachedRetriever.getFromCache(relayUrl), - relayUrl, + Nip11CachedRetriever.getFromCache(relay), + relay, ) { accountViewModel.retrieveRelayDocument( - relayUrl, - onInfo = { - value = it - }, - onError = { url, errorCode, exceptionMessage -> - }, + relay = relay, + onInfo = { value = it }, + onError = { url, errorCode, exceptionMessage -> }, ) } @@ -124,14 +120,10 @@ private fun DrawRelayIcon( accountViewModel: AccountViewModel, ) { val relayInfo by loadRelayInfo(channel.roomId.relayUrl, accountViewModel) - val info = - remember(channel.roomId.relayUrl) { - RelayBriefInfoCache.get(RelayUrlFormatter.normalize(channel.roomId.relayUrl)) - } RobohashFallbackAsyncImage( robot = channel.idHex, - model = relayInfo?.icon ?: info.favIcon, + model = relayInfo?.icon, contentDescription = stringRes(R.string.profile_image), contentScale = ContentScale.Crop, modifier = HeaderPictureModifier, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt index 7cfd03779..82fb3225e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatMetaViewModel.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer class NewEphemeralChatMetaViewModel : ViewModel() { private var account: Account? = null @@ -42,7 +43,9 @@ class NewEphemeralChatMetaViewModel : ViewModel() { this.account = account } - fun buildRoom() = RoomId(channelName.value.text, relayUrl.value.text) + fun buildRoom() = + RelayUrlNormalizer.normalizeOrNull(relayUrl.value.text) + ?.let { RoomId(channelName.value.text, it) } /* fun createOrUpdate(onDone: (RoomId) -> Unit) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt index e6ec5d236..2a8cb3658 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/metadata/NewEphemeralChatScreen.kt @@ -124,8 +124,10 @@ private fun ChannelMetadataScaffold( JoinButton( onPost = { - nav.popBack() - nav.nav(routeFor(postViewModel.buildRoom())) + postViewModel.buildRoom()?.let { + nav.popBack() + nav.nav(routeFor(it)) + } }, postViewModel.canPost, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt index 3814ef2fd..b5a872aee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataScreen.kt @@ -229,7 +229,7 @@ private fun ChannelMetadataScaffold( ) } - itemsIndexed(feedState, key = { _, item -> "ChatRelays" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "ChatRelays" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteHomeRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt index 91953ca27..ff1c0ab19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt @@ -84,7 +84,7 @@ class ChannelMetadataViewModel : ViewModel() { val relays = channel.info.relays ?.map { relaySetupInfoBuilder(it) } - ?.distinctBy { it.url } + ?.distinctBy { it.relay } _channelRelays.update { relays ?: emptyList() } } @@ -102,19 +102,17 @@ class ChannelMetadataViewModel : ViewModel() { channelName.value.text, channelDescription.value.text, channelPicture.value.text, - channelRelays.value.map { it.url }, + channelRelays.value.map { it.relay }, ) account.signAndSendPrivatelyOrBroadcast( template, relayList = { it.channelInfo().relays }, onDone = { - val channel = LocalCache.getOrCreateChannel(it.id) { PublicChatChannel(it) } - if (channel is PublicChatChannel) { - // follows the channel - account.follow(channel) - onDone(channel) - } + val channel = LocalCache.getOrCreatePublicChatChannel(it.id) + // follows the channel + account.follow(channel) + onDone(channel) }, ) } else { @@ -128,7 +126,7 @@ class ChannelMetadataViewModel : ViewModel() { channelName.value.text, channelDescription.value.text, channelPicture.value.text, - channelRelays.value.map { it.url }, + channelRelays.value.map { it.relay }, hint, ) } else { @@ -138,7 +136,7 @@ class ChannelMetadataViewModel : ViewModel() { channelName.value.text, channelDescription.value.text, channelPicture.value.text, - channelRelays.value.map { it.url }, + channelRelays.value.map { it.relay }, eTag, ) } @@ -147,10 +145,8 @@ class ChannelMetadataViewModel : ViewModel() { template, relayList = { it.channelInfo().relays }, onDone = { - val channel = LocalCache.getOrCreateChannel(it.id) { PublicChatChannel(it) } - if (channel is PublicChatChannel) { - onDone(channel) - } + val channel = LocalCache.getOrCreatePublicChatChannel(it.id) + onDone(channel) }, ) } @@ -161,7 +157,7 @@ class ChannelMetadataViewModel : ViewModel() { } fun addHomeRelay(relay: BasicRelaySetupInfo) { - if (_channelRelays.value.any { it.url == relay.url }) return + if (_channelRelays.value.any { it.relay == relay.relay }) return _channelRelays.update { it.plus(relay) } } 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 5ef665f55..e696c4597 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 @@ -109,7 +109,7 @@ fun LongLiveActivityChannelHeader( if (summary != null) { baseChannel.info?.let { if (it.hasHashtags()) { - DisplayUncitedHashtags(it, summary, nav) + DisplayUncitedHashtags(it, summary, accountViewModel, nav) } } } 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 a2b3b03a3..18b0fa822 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 @@ -273,7 +273,7 @@ open class ChannelNewMessageViewModel : suspend fun sendPostSync() { val template = createTemplate() ?: return - val channelRelays = channel?.relays() ?: emptyList() + val channelRelays = channel?.relays() ?: emptySet() accountViewModel?.account?.signAndSendPrivately(template, channelRelays) 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 a50d7de05..01f31b15b 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 @@ -82,14 +82,12 @@ import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip37Drafts.DraftEvent -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter @Composable fun ChatroomHeaderCompose( @@ -229,10 +227,6 @@ private fun ChannelRoomCompose( val channelState by observeChannel(channel, accountViewModel) val relayInfo by loadRelayInfo(channel.roomId.relayUrl, accountViewModel) - val info = - remember(channel.roomId.relayUrl) { - RelayBriefInfoCache.get(RelayUrlFormatter.normalize(channel.roomId.relayUrl)) - } val channelName = channelState?.channel?.toBestDisplayName() ?: channel.toBestDisplayName() @@ -253,7 +247,7 @@ private fun ChannelRoomCompose( ChannelName( channelIdHex = channel.idHex, - channelPicture = relayInfo?.icon ?: info.favIcon, + channelPicture = relayInfo?.icon, channelTitle = { modifier -> ChannelTitleWithLabelInfo(channelName, R.string.ephemeral_relay_chat, modifier) }, channelLastTime = note.createdAt(), channelLastContent = "$authorName: $description", diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index b691d4701..83f10b485 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -57,7 +57,7 @@ class ChatroomListKnownFeedFilter( val publicChannels = account - .publicChatList.livePublicChatList.value + .publicChatList.flow.value .mapNotNull { LocalCache.getChannelIfExists(it.eventId) } .mapNotNull { it -> it.notes @@ -168,7 +168,7 @@ class ChatroomListKnownFeedFilter( newItems: Set, account: Account, ): MutableMap { - val followingChannels = account.publicChatList.livePublicChatEventIdSet.value + val followingChannels = account.publicChatList.flowSet.value val newRelevantPublicMessages = mutableMapOf() newItems .filter { it.event is ChannelMessageEvent } @@ -201,7 +201,7 @@ class ChatroomListKnownFeedFilter( if (noteEvent != null) { val room = noteEvent.roomId() - if (room in followingEphemeralChats && account.isAcceptable(newNote)) { + if (room != null && room in followingEphemeralChats && account.isAcceptable(newNote)) { val lastNote = newRelevantEphemeralChats.get(room) if (lastNote != null) { if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt index 13dbf03f5..4649f1c2b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient // This allows multiple screen to be listening to tags, even the same tag class ChatroomListState( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt index 134c88999..844b1f67d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt @@ -20,10 +20,18 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.ammolite.relays.datasources.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlin.collections.forEach class DMsFromUserFilterSubAssembler( client: NostrClient, @@ -31,11 +39,45 @@ class DMsFromUserFilterSubAssembler( ) : PerUserEoseManager(client, allKeys) { override fun updateFilter( key: ChatroomListState, - since: Map?, - ): List? = - listOfNotNull( - filterNip04DMsToAndFromMe(key.account.userProfile().pubkeyHex, since), - ).flatten() + since: SincePerRelayMap?, + ): List? = + key.account.outboxRelays.flow.value.map { + filterNip04DMsFromMe(key.account.userProfile(), it, since?.get(it)?.time) + } + + key.account.dmRelays.flow.value.map { + filterNip04DMsToMe(key.account.userProfile(), it, since?.get(it)?.time) + } override fun user(key: ChatroomListState) = key.account.userProfile() + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: ChatroomListState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.Default) { + key.account.outboxRelays.flow.collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.Default) { + key.account.dmRelays.flow.collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt index 111b96f05..1c1c68f19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt @@ -20,39 +20,44 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.utils.mapOfSet fun filterFollowingEphemeralChats( - followingChannels: Set?, - since: Map?, -): List? { - if (followingChannels == null || followingChannels.isEmpty()) return null + followingChannels: Set, + since: SincePerRelayMap?, +): List? { + if (followingChannels.isEmpty()) return null - val dTags = - followingChannels.map { - if (it.id.isBlank()) { - "_" - } else { - it.id + val relayRoomDTags = + mapOfSet { + followingChannels.forEach { room -> + if (!room.relayUrl.url.isBlank()) { + add(room.relayUrl, room.id.ifBlank { "_" }) + } else { + Constants.eventFinderRelays.forEach { + add(it, room.id.ifBlank { "_" }) + } + } } } - return listOf( - TypedFilter( + return relayRoomDTags.map { + RelayBasedFilter( // Metadata comes from any relay - types = setOf(FeedType.PUBLIC_CHATS), + relay = it.key, filter = - SincePerRelayFilter( + Filter( kinds = listOf(EphemeralChatEvent.KIND), - tags = mapOf("d" to dTags), + tags = mapOf("d" to it.value.sorted()), limit = 100, - since = since, + since = since?.get(it.key)?.time, ), - ), - ) + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt index acc485742..e92020f0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt @@ -20,29 +20,44 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent -import kotlin.collections.toList +import com.vitorpamplona.quartz.utils.mapOfSet fun filterFollowingPublicChats( - followingChannels: Set?, - since: Map?, -): List? { - if (followingChannels == null || followingChannels.isEmpty()) return null + followingChannels: Set, + since: SincePerRelayMap?, +): List? { + if (followingChannels.isEmpty()) return null - return listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, + val relayRoomDTags = + mapOfSet { + followingChannels.forEach { channelId -> + val relays = + LocalCache.getChannelIfExists(channelId)?.relays() + ?: LocalCache.relayHints.hintsForEvent(channelId).ifEmpty { null } + ?: Constants.eventFinderRelays + + relays.forEach { relayUrl -> + add(relayUrl, channelId) + } + } + } + + return relayRoomDTags.map { + RelayBasedFilter( + relay = it.key, filter = - SincePerRelayFilter( + Filter( kinds = listOf(ChannelCreateEvent.KIND), - ids = followingChannels.toList(), - since = since, + ids = it.value.sorted(), + since = since?.get(it.key)?.time, ), - ), - ) + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt index 7fe0b34d4..59b1288c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt @@ -20,44 +20,60 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import kotlin.collections.toList +import com.vitorpamplona.quartz.utils.mapOfSet fun filterLastMessageFollowingPublicChats( - followingChannels: Set?, - since: Map?, -): List? { - if (followingChannels == null || followingChannels.isEmpty()) return null + followingChannels: Set, + since: SincePerRelayMap?, +): List? { + if (followingChannels.isEmpty()) return null - return listOf( - TypedFilter( - // Metadata comes from any relay - types = EVENT_FINDER_TYPES, - filter = - SincePerRelayFilter( - kinds = listOf(ChannelMetadataEvent.KIND), - tags = mapOf("e" to followingChannels.toList()), - since = since, - limit = 1, - ), - ), - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SincePerRelayFilter( - kinds = listOf(ChannelMessageEvent.KIND), - tags = mapOf("e" to followingChannels.toList()), - since = since, - // Remember to consider spam that is being removed from the UI - limit = 100, - ), - ), - ) + val relayRoomDTags = + mapOfSet { + followingChannels.forEach { channelId -> + val relays = + LocalCache.getChannelIfExists(channelId)?.relays() + ?: LocalCache.relayHints.hintsForEvent(channelId).ifEmpty { null } + ?: Constants.eventFinderRelays + + relays.forEach { relayUrl -> + add(relayUrl, channelId) + } + } + } + + return relayRoomDTags.map { + listOf( + RelayBasedFilter( + // Metadata comes from any relay + relay = it.key, + filter = + Filter( + kinds = listOf(ChannelMetadataEvent.KIND), + tags = mapOf("e" to it.value.sorted()), + since = since?.get(it.key)?.time, + limit = 1, + ), + ), + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(ChannelMessageEvent.KIND), + tags = mapOf("e" to it.value.sorted()), + since = since?.get(it.key)?.time, + // Remember to consider spam that is being removed from the UI + limit = 100, + ), + ), + ) + }.flatten() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt new file mode 100644 index 000000000..afadd3b35 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2025 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.rooms.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent + +fun filterNip04DMsFromMe( + user: User, + relay: NormalizedRelayUrl, + since: Long?, +): RelayBasedFilter { + return RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = listOf(user.pubkeyHex), + since = since, + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToAndFromMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToAndFromMe.kt deleted file mode 100644 index 413bbdab2..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToAndFromMe.kt +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Copyright (c) 2025 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.rooms.datasource - -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent - -fun filterNip04DMsToAndFromMe( - user: HexKey?, - since: Map?, -): List? { - if (user == null || user.isEmpty()) return null - - return listOf( - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = - SincePerRelayFilter( - kinds = listOf(PrivateDmEvent.KIND), - tags = mapOf("p" to listOf(user)), - since = since, - ), - ), - TypedFilter( - types = setOf(FeedType.PRIVATE_DMS), - filter = - SincePerRelayFilter( - kinds = listOf(PrivateDmEvent.KIND), - authors = listOf(user), - since = since, - ), - ), - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt new file mode 100644 index 000000000..b86aa6280 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2025 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.rooms.datasource + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent + +fun filterNip04DMsToMe( + user: User, + relay: NormalizedRelayUrl, + since: Long?, +): RelayBasedFilter { + return RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + tags = mapOf("p" to listOf(user.pubkeyHex)), + since = since, + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt index b1dfcbd56..8d3595b26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt @@ -22,10 +22,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -40,8 +40,8 @@ class FollowingEphemeralChatSubAssembler( ) : PerUserEoseManager(client, allKeys) { override fun updateFilter( key: ChatroomListState, - since: Map?, - ): List? = + since: SincePerRelayMap?, + ): List? = listOfNotNull( filterFollowingEphemeralChats(key.account.ephemeralChatList.liveEphemeralChatList.value, since), ).flatten() @@ -69,7 +69,7 @@ class FollowingEphemeralChatSubAssembler( key: User, subId: String, ) { - return super.endSub(key, subId) + super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt index dc5e03525..61b3de874 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt @@ -22,10 +22,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -40,11 +40,11 @@ class FollowingPublicChatSubAssembler( ) : PerUserEoseManager(client, allKeys) { override fun updateFilter( key: ChatroomListState, - since: Map?, - ): List? = + since: SincePerRelayMap?, + ): List? = listOfNotNull( - filterLastMessageFollowingPublicChats(key.account.publicChatList.livePublicChatEventIdSet.value, since), - filterFollowingPublicChats(key.account.publicChatList.livePublicChatEventIdSet.value, since), + filterLastMessageFollowingPublicChats(key.account.publicChatList.flowSet.value, since), + filterFollowingPublicChats(key.account.publicChatList.flowSet.value, since), ).flatten() override fun user(key: ChatroomListState) = key.account.userProfile() @@ -57,7 +57,7 @@ class FollowingPublicChatSubAssembler( userJobMap[key.account.userProfile()] = listOf( key.account.scope.launch(Dispatchers.Default) { - key.account.publicChatList.livePublicChatEventIdSet.sample(5000).collectLatest { + key.account.publicChatList.flowSet.sample(5000).collectLatest { invalidateFilters() } }, @@ -70,7 +70,7 @@ class FollowingPublicChatSubAssembler( key: User, subId: String, ) { - return super.endSub(key, subId) + super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt index 3a84eea9c..b7065770a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt @@ -148,7 +148,7 @@ private fun ImageVideoPostChat( fileUploadState: ChatFileUploadState, accountViewModel: AccountViewModel, ) { - val fileServers by accountViewModel.account.liveServerList.collectAsState() + val fileServers by accountViewModel.account.serverLists.liveServerList.collectAsState() val fileServerOptions = remember(fileServers) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt index a9d06b304..bb630d892 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt @@ -20,11 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import kotlin.collections.ifEmpty class CommunityFeedFilterSubAssembler( client: NostrClient, @@ -32,16 +34,23 @@ class CommunityFeedFilterSubAssembler( ) : SingleSubEoseManager(client, allKeys) { override fun updateFilter( keys: List, - since: Map?, - ): List? { + since: SincePerRelayMap?, + ): List { if (keys.isEmpty()) return emptyList() - return keys.mapNotNull { + return keys.flatMap { val commEvent = it.community.event if (commEvent is CommunityDefinitionEvent) { - filterCommunityPosts(commEvent, since) + val relays = + commEvent.relayUrls().ifEmpty { null } + ?: LocalCache.relayHints.hintsForAddress(commEvent.addressTag()).ifEmpty { null } + ?: it.community.relayUrls() + + relays.toSet().map { + filterCommunityPosts(it, commEvent, since?.get(it)?.time) + } } else { - null + emptyList() } } } 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 index a9ec56023..bc9087f18 100644 --- 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 @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient // This allows multiple screen to be listening to tags, even the same tag class CommunityQueryState( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt index 7cd506d89..e0447aae2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt @@ -20,25 +20,26 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent fun filterCommunityPosts( + relay: NormalizedRelayUrl, community: CommunityDefinitionEvent, - since: Map?, -): TypedFilter = - TypedFilter( - types = COMMON_FEED_TYPES, + since: Long?, +): RelayBasedFilter { + return RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( authors = community.moderators().map { it.pubKey }.plus(community.pubKey), tags = mapOf("a" to listOf(community.addressTag())), - kinds = listOf(CommunityPostApprovalEvent.KIND), + kinds = CommunityPostApprovalEvent.KIND_LIST, limit = 500, since = since, ), ) +} 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 index f7de6508a..e820995bc 100644 --- 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 @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import kotlinx.coroutines.CoroutineScope // This allows multiple screen to be listening to tags, even the same tag @@ -36,8 +36,9 @@ class DiscoveryFilterAssembler( ) : ComposeSubscriptionManager() { val group = listOf( - DiscoveryFollowsDiscoverySubAssembler(client, ::allKeys), - MixGeohashHashtagsDiscoverySubAssembler(client, ::allKeys), + DiscoveryFollowsDiscoverySubAssembler1(client, ::allKeys), + DiscoveryFollowsDiscoverySubAssembler2(client, ::allKeys), + DiscoveryFollowsDiscoverySubAssembler3(client, ::allKeys), ) override fun start() = group.forEach { it.start() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler1.kt similarity index 62% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler1.kt index 6f6b41e81..52464b051 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler1.kt @@ -20,37 +20,36 @@ */ 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.eoseManagers.PerUserAndFollowListEoseManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByFollows -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByFollows -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByFollows -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByFollows -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByFollows -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByFollows -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByFollows -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.makeLongFormFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.makeContentDVMsFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.makeClassifiedsFilter import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch -import kotlin.collections.flatten -class DiscoveryFollowsDiscoverySubAssembler( +class DiscoveryFollowsDiscoverySubAssembler1( client: NostrClient, allKeys: () -> Set, ) : PerUserAndFollowListEoseManager(client, allKeys) { override fun updateFilter( key: DiscoveryQueryState, - since: Map?, - ): List? = updateFilter(key.followsPerRelay(), key.followLists(), since) + since: SincePerRelayMap?, + ): List? { + val feedSettings = key.followsPerRelay() + + return makeLongFormFilter(feedSettings, since) + + makeClassifiedsFilter(feedSettings, since) + + makeContentDVMsFilter(feedSettings, since) + } override fun user(key: DiscoveryQueryState) = key.account.userProfile() @@ -60,32 +59,10 @@ class DiscoveryFollowsDiscoverySubAssembler( fun DiscoveryQueryState.listName() = listNameFlow().value - fun DiscoveryQueryState.followListsFlow() = account.liveDiscoveryFollowLists - - fun DiscoveryQueryState.followLists() = followListsFlow().value - - fun DiscoveryQueryState.followsPerRelayFlow() = account.liveDiscoveryListAuthorsPerRelay + fun DiscoveryQueryState.followsPerRelayFlow() = account.liveDiscoveryFollowListsPerRelay fun DiscoveryQueryState.followsPerRelay() = followsPerRelayFlow().value - fun updateFilter( - follows: Map>?, - followLists: Account.LiveFollowList?, - since: Map?, - ): List? { - if (follows != null && follows.isEmpty()) return null - - return listOfNotNull( - filterClassifiedsByFollows(follows, since), - filterFollowSetsByFollows(follows, since), - filterLongFormByFollows(follows, since), - filterPublicChatsByFollows(follows, since), - filterContentDVMsByFollows(follows, since), - filterLiveActivitiesByFollows(follows, followLists?.authorsPlusMe, since), - filterCommunitiesByFollows(follows, since), - ).flatten() - } - val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) @@ -113,7 +90,7 @@ class DiscoveryFollowsDiscoverySubAssembler( key: User, subId: String, ) { - return super.endSub(key, subId) + super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler2.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler2.kt new file mode 100644 index 000000000..922385f2e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler2.kt @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2025 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.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.makeFollowSetsFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.makeLiveActivitiesFilter +import com.vitorpamplona.ammolite.relays.datasources.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class DiscoveryFollowsDiscoverySubAssembler2( + client: NostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: DiscoveryQueryState, + since: SincePerRelayMap?, + ): List? { + val feedSettings = key.followsPerRelay() + + return makeFollowSetsFilter(feedSettings, since) + + makeLiveActivitiesFilter(feedSettings, since) + } + + override fun user(key: DiscoveryQueryState) = key.account.userProfile() + + override fun list(key: DiscoveryQueryState) = key.listName() + + fun DiscoveryQueryState.listNameFlow() = account.settings.defaultDiscoveryFollowList + + fun DiscoveryQueryState.listName() = listNameFlow().value + + fun DiscoveryQueryState.followsPerRelayFlow() = account.liveDiscoveryFollowListsPerRelay + + fun DiscoveryQueryState.followsPerRelay() = followsPerRelayFlow().value + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: DiscoveryQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.Default) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.Default) { + key.followsPerRelayFlow().sample(5000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxUsersEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler3.kt similarity index 60% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxUsersEoseManager.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler3.kt index c2efb4927..0c9d240ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxUsersEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler3.kt @@ -18,15 +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.ui.screen.loggedIn.home.datasource.nip65Follows +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.makePublicChatsFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.makeCommunitiesFilter import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -34,31 +35,36 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch -class HomeOutboxUsersEoseManager( +class DiscoveryFollowsDiscoverySubAssembler3( client: NostrClient, - allKeys: () -> Set, -) : PerUserAndFollowListEoseManager(client, allKeys) { + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { override fun updateFilter( - key: HomeQueryState, - since: Map?, - ): List? = filterUserMetadataByFollows(key.followRelay(), since) + key: DiscoveryQueryState, + since: SincePerRelayMap?, + ): List? { + val feedSettings = key.followsPerRelay() - override fun user(key: HomeQueryState) = key.account.userProfile() + return makePublicChatsFilter(feedSettings, since) + + makeCommunitiesFilter(feedSettings, since) + } - override fun list(key: HomeQueryState) = key.listName() + override fun user(key: DiscoveryQueryState) = key.account.userProfile() - fun HomeQueryState.listNameFlow() = account.settings.defaultHomeFollowList + override fun list(key: DiscoveryQueryState) = key.listName() - fun HomeQueryState.listName() = listNameFlow().value + fun DiscoveryQueryState.listNameFlow() = account.settings.defaultDiscoveryFollowList - fun HomeQueryState.followRelayFlow() = account.liveHomeListAuthorsPerRelay + fun DiscoveryQueryState.listName() = listNameFlow().value - fun HomeQueryState.followRelay() = followRelayFlow().value + fun DiscoveryQueryState.followsPerRelayFlow() = account.liveDiscoveryFollowListsPerRelay + + fun DiscoveryQueryState.followsPerRelay() = followsPerRelayFlow().value val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) - override fun newSub(key: HomeQueryState): Subscription { + override fun newSub(key: DiscoveryQueryState): Subscription { val user = user(key) userJobMap[user]?.forEach { it.cancel() } userJobMap[user] = @@ -69,7 +75,7 @@ class HomeOutboxUsersEoseManager( } }, key.scope.launch(Dispatchers.Default) { - key.followRelayFlow().sample(5000).collectLatest { + key.followsPerRelayFlow().sample(5000).collectLatest { invalidateFilters() } }, @@ -82,7 +88,7 @@ class HomeOutboxUsersEoseManager( key: User, subId: String, ) { - return super.endSub(key, subId) + super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/MixGeohashHashtagsDiscoverySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/MixGeohashHashtagsDiscoverySubAssembler.kt deleted file mode 100644 index 51660df20..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/MixGeohashHashtagsDiscoverySubAssembler.kt +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Copyright (c) 2025 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.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByGeohash -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByHashtag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByGeohash -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByHashtag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByGeohash -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByHashtag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByGeohash -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByHashtag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunityPostsByGeohash -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunityPostsByHashtag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByGeohash -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByHashtag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByGeohash -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByHashtag -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.EOSETime -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class MixGeohashHashtagsDiscoverySubAssembler( - client: NostrClient, - allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun updateFilter( - key: DiscoveryQueryState, - since: Map?, - ): List? = updateFilter(key.followLists()?.hashtags, key.followLists()?.geotags, since) - - fun updateFilter( - hashtags: Set?, - geotags: Set?, - since: Map?, - ): List? { - val hashtagFilters = - if (hashtags != null && !hashtags.isEmpty()) { - listOfNotNull( - filterClassifiedsByHashtag(hashtags, since), - filterPublicChatsByHashtag(hashtags, since), - filterFollowSetsByHashtag(hashtags, since), - filterLongFormByHashtag(hashtags, since), - filterContentDVMsByHashtag(hashtags, since), - filterLiveActivitiesByHashtag(hashtags, since), - filterCommunityPostsByHashtag(hashtags, since), - ).flatten() - } else { - emptyList() - } - - val geoHashFilters = - if (geotags != null && !geotags.isEmpty()) { - listOfNotNull( - filterClassifiedsByGeohash(geotags, since), - filterPublicChatsByGeohash(geotags, since), - filterFollowSetsByGeohash(geotags, since), - filterLongFormByGeohash(geotags, since), - filterContentDVMsByGeohash(geotags, since), - filterLiveActivitiesByGeohash(geotags, since), - filterCommunityPostsByGeohash(geotags, since), - ).flatten() - } else { - emptyList() - } - - return hashtagFilters + geoHashFilters - } - - override fun user(key: DiscoveryQueryState) = key.account.userProfile() - - fun DiscoveryQueryState.followListsFlow() = account.liveDiscoveryFollowLists - - fun DiscoveryQueryState.followLists() = followListsFlow().value - - val userJobMap = mutableMapOf() - - override fun newSub(key: DiscoveryQueryState): Subscription { - val user = user(key) - userJobMap[user]?.cancel() - userJobMap[user] = - key.scope.launch(Dispatchers.Default) { - key.followListsFlow().collectLatest { - invalidateFilters() - } - } - - return super.newSub(key) - } - - override fun endSub( - key: User, - subId: String, - ) { - return super.endSub(key, subId) - userJobMap[key]?.cancel() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt index 868092d8b..56140e4d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt @@ -56,11 +56,9 @@ open class DiscoverLongFormFeedFilter( override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) fun buildFilterParams(account: Account): FilterByListParams = - FilterByListParams.Companion.create( - account.userProfile().pubkeyHex, - account.settings.defaultDiscoveryFollowList.value, + FilterByListParams.create( account.liveDiscoveryFollowLists.value, - account.flowHiddenUsers.value, + account.hiddenUsers.flow.value, ) protected open fun innerApplyFilter(collection: Collection): Set { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt new file mode 100644 index 000000000..9083c8e90 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 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.nip23LongForm + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeLongFormFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + return when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterLongFormByAllCommunities(feedSettings, since) + is AllFollowsByOutboxTopNavPerRelayFilterSet -> filterLongFormByFollows(feedSettings, since) + is AuthorsByOutboxTopNavPerRelayFilterSet -> filterLongFormByAuthors(feedSettings, since) + is GlobalTopNavPerRelayFilterSet -> filterLongFormGlobal(feedSettings, since) + is HashtagTopNavPerRelayFilterSet -> filterLongFormByHashtag(feedSettings, since) + is LocationTopNavPerRelayFilterSet -> filterLongFormByGeohash(feedSettings, since) + is MutedAuthorsByOutboxTopNavPerRelayFilterSet -> filterLongFormByAuthors(feedSettings, since) + is SingleCommunityTopNavPerRelayFilterSet -> filterLongFormByCommunity(feedSettings, since) + else -> emptyList() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt new file mode 100644 index 000000000..f08543c74 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2025 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.nip23LongForm.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten + +fun filterLongFormAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(LongTextNoteEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("k" to listOf("5300"), "a" to communityList), + kinds = listOf(LongTextNoteEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterLongFormByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return communitySet.set.mapNotNull { + filterLongFormAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt new file mode 100644 index 000000000..838d31e00 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2025 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.nip23LongForm.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten +import kotlin.collections.mapNotNull + +fun filterLongFormAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(LongTextNoteEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterLongFormByAuthors( + authorSet: AuthorsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return authorSet.set.mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterLongFormAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterLongFormByAuthors( + authorSet: MutedAuthorsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return authorSet.set.mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterLongFormAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt new file mode 100644 index 000000000..81570692b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2025 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.nip23LongForm.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten + +fun filterLongFormByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to listOf(LongTextNoteEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), + kinds = listOf(LongTextNoteEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterLongFormByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return communitySet.set.mapNotNull { + filterLongFormByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByFollows.kt index e8eb23795..c3e1a7d47 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByFollows.kt @@ -20,29 +20,37 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatMap fun filterLongFormByFollows( - follows: Map>?, - since: Map?, -): List? { - if (follows != null && follows.isEmpty()) return null + followsSet: AllFollowsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (followsSet.set.isEmpty()) return emptyList() - return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(LongTextNoteEvent.KIND), - limit = 300, - since = since, - ), - ), - ) + val defaultSince = TimeUtils.oneWeekAgo() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterLongFormAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterLongFormByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterLongFormByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterLongFormAllCommunities(relay, it, since) + }, + ).flatten() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt index 88a4edd45..51f81e137 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt @@ -20,25 +20,30 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByGeohash +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.mapNotNull fun filterLongFormByGeohash( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() - val geoHashes = hashToLoad.toList() + val geoHashes = geotags.sorted() return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( kinds = listOf(LongTextNoteEvent.KIND), tags = mapOf("g" to geoHashes), limit = 300, @@ -47,3 +52,24 @@ fun filterLongFormByGeohash( ), ) } + +fun filterLongFormByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return geoSet.set.mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterLiveActivitiesByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt index b63040209..71afa309c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt @@ -20,30 +20,31 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.mapNotNull fun filterLongFormByHashtag( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long?, +): List { + if (hashtags.isEmpty()) return emptyList() - val hashtags = hashtagAlts(hashToLoad).toList() + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - LongTextNoteEvent.KIND, - ), + Filter( + kinds = listOf(LongTextNoteEvent.KIND), tags = mapOf("t" to hashtags), limit = 300, since = since, @@ -51,3 +52,24 @@ fun filterLongFormByHashtag( ), ) } + +fun filterLongFormByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return hashSet.set.mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterLongFormByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt new file mode 100644 index 000000000..c5445f7f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2025 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.nip23LongForm.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +fun filterLongFormGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (relays.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return relays.set.map { + val since = since?.get(it.key)?.time ?: defaultSince + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(LongTextNoteEvent.KIND), + limit = 300, + since = since, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/DiscoverChatFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/DiscoverChatFeedFilter.kt index fdf74d7af..f43b0e5a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/DiscoverChatFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/DiscoverChatFeedFilter.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats 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 @@ -46,16 +45,12 @@ open class DiscoverChatFeedFilter( val params = buildFilterParams(account) val allChannelNotes = - LocalCache.channels.mapNotNullIntoSet { _, channel -> - if (channel is PublicChatChannel) { - val note = LocalCache.getNoteIfExists(channel.idHex) - val noteEvent = note?.event + LocalCache.publicChatChannels.mapNotNullIntoSet { _, channel -> + val note = LocalCache.getNoteIfExists(channel.idHex) + val noteEvent = note?.event - if (noteEvent == null || params.match(noteEvent)) { - note - } else { - null - } + if (noteEvent == null || params.match(noteEvent)) { + note } else { null } @@ -67,11 +62,9 @@ open class DiscoverChatFeedFilter( override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) fun buildFilterParams(account: Account): FilterByListParams = - FilterByListParams.Companion.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultDiscoveryFollowList.value, + FilterByListParams.create( followLists = account.liveDiscoveryFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) protected open fun innerApplyFilter(collection: Collection): Set { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderChannelThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderChannelThumb.kt index f4f8ddbb7..70249b8dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderChannelThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderChannelThumb.kt @@ -45,6 +45,10 @@ import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ParticipantListBuilder import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.ui.navigation.INav @@ -106,9 +110,17 @@ fun RenderChannelThumb( LaunchedEffect(key1 = channelUpdates) { launch(Dispatchers.IO) { - val followingKeySet = - accountViewModel.account.liveDiscoveryFollowLists.value - ?.authors + val topFilter = accountViewModel.account.liveDiscoveryFollowLists.value + val topFilterAuthors = + when (topFilter) { + is AuthorsByOutboxTopNavFilter -> topFilter.authors + is MutedAuthorsByOutboxTopNavFilter -> topFilter.authors + is AllFollowsByOutboxTopNavFilter -> topFilter.authors + is SingleCommunityTopNavFilter -> topFilter.authors + else -> null + } + + val followingKeySet = topFilterAuthors val allParticipants = ParticipantListBuilder() .followsThatParticipateOn(baseNote, followingKeySet) @@ -116,7 +128,7 @@ fun RenderChannelThumb( val newParticipantUsers = if (followingKeySet == null) { - val allFollows = accountViewModel.account.liveKind3Follows.value.authors + val allFollows = accountViewModel.account.kind3FollowList.flow.value.authors val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt new file mode 100644 index 000000000..d3db8579c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 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.nip28Chats + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makePublicChatsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + return when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterPublicChatsByAllCommunities(feedSettings, since) + is AllFollowsByOutboxTopNavPerRelayFilterSet -> filterPublicChatsByFollows(feedSettings, since) + is AuthorsByOutboxTopNavPerRelayFilterSet -> filterPublicChatsByAuthors(feedSettings, since) + is GlobalTopNavPerRelayFilterSet -> filterPublicChatsGlobal(feedSettings, since) + is HashtagTopNavPerRelayFilterSet -> filterPublicChatsByHashtag(feedSettings, since) + is LocationTopNavPerRelayFilterSet -> filterPublicChatsByGeohash(feedSettings, since) + is MutedAuthorsByOutboxTopNavPerRelayFilterSet -> filterPublicChatsByAuthors(feedSettings, since) + is SingleCommunityTopNavPerRelayFilterSet -> filterPublicChatsByCommunity(feedSettings, since) + else -> emptyList() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt new file mode 100644 index 000000000..130409fa6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2025 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.nip28Chats.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +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.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten + +fun filterPublicChatsAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(ChannelCreateEvent.KIND.toString(), ChannelMetadataEvent.KIND.toString(), ChannelMessageEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("k" to listOf("5300"), "a" to communityList), + kinds = + listOf( + ChannelCreateEvent.KIND, + ChannelMetadataEvent.KIND, + ChannelMessageEvent.KIND, + ), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterPublicChatsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return communitySet.set.mapNotNull { + filterPublicChatsAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt new file mode 100644 index 000000000..3aedb1875 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2025 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.nip28Chats.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +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.utils.TimeUtils +import kotlin.collections.flatten +import kotlin.collections.mapNotNull + +fun filterPublicChatsAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = + listOf( + ChannelCreateEvent.KIND, + ChannelMetadataEvent.KIND, + ChannelMessageEvent.KIND, + ), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterPublicChatsByAuthors( + authorSet: AuthorsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return authorSet.set.mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPublicChatsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterPublicChatsByAuthors( + authorSet: MutedAuthorsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return authorSet.set.mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPublicChatsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt new file mode 100644 index 000000000..ce0975292 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2025 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.nip28Chats.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +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.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten + +fun filterPublicChatsByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to listOf(ChannelCreateEvent.KIND.toString(), ChannelMetadataEvent.KIND.toString(), ChannelMessageEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), + kinds = + listOf( + ChannelCreateEvent.KIND, + ChannelMetadataEvent.KIND, + ChannelMessageEvent.KIND, + ), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterPublicChatsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return communitySet.set.mapNotNull { + filterPublicChatsByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByFollows.kt index 597fe822b..1c57ba374 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByFollows.kt @@ -20,29 +20,37 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatMap fun filterPublicChatsByFollows( - follows: Map>?, - since: Map?, -): List? { - if (follows != null && follows.isEmpty()) return null + followsSet: AllFollowsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (followsSet.set.isEmpty()) return emptyList() - return listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(ChannelMessageEvent.KIND), - limit = 500, - since = since, - ), - ), - ) + val defaultSince = TimeUtils.oneWeekAgo() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterPublicChatsAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterPublicChatsByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterPublicChatsByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterPublicChatsAllCommunities(relay, it, since) + }, + ).flatten() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt index ecd45458c..fafc069a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt @@ -20,27 +20,31 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl 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.utils.TimeUtils +import kotlin.collections.mapNotNull fun filterPublicChatsByGeohash( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() - val geoHashes = hashToLoad.toList() + val geoHashes = geotags.sorted() return listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( kinds = listOf( ChannelCreateEvent.KIND, @@ -54,3 +58,24 @@ fun filterPublicChatsByGeohash( ), ) } + +fun filterPublicChatsByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return geoSet.set.mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterPublicChatsByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt index 7a4570c22..d7a69d40b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt @@ -20,28 +20,32 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts 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.utils.TimeUtils +import kotlin.collections.mapNotNull fun filterPublicChatsByHashtag( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long?, +): List { + if (hashtags.isEmpty()) return emptyList() - val hashtags = hashtagAlts(hashToLoad).toList() + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() return listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( kinds = listOf( ChannelCreateEvent.KIND, @@ -55,3 +59,24 @@ fun filterPublicChatsByHashtag( ), ) } + +fun filterPublicChatsByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return hashSet.set.mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterPublicChatsByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunityPostsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt similarity index 54% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunityPostsByHashtag.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt index 72fdf25b6..3b4062471 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunityPostsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt @@ -18,38 +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.ui.screen.loggedIn.discover.nip72Communities.subassemblies +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts -import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +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.utils.TimeUtils -fun filterCommunityPostsByHashtag( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null +fun filterPublicChatsGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (relays.set.isEmpty()) return emptyList() - val hashtags = hashtagAlts(hashToLoad).toList() + val defaultSince = TimeUtils.oneWeekAgo() - return listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), + return relays.set.map { + val since = since?.get(it.key)?.time ?: defaultSince + RelayBasedFilter( + relay = it.key, filter = - SincePerRelayFilter( + Filter( kinds = listOf( - CommunityDefinitionEvent.KIND, - CommunityPostApprovalEvent.KIND, + ChannelCreateEvent.KIND, + ChannelMetadataEvent.KIND, + ChannelMessageEvent.KIND, ), - tags = mapOf("t" to hashtags), limit = 300, since = since, ), - ), - ) + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt index 8f0dd3b7b..d1a9c0d80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt @@ -56,11 +56,9 @@ open class DiscoverFollowSetsFeedFilter( override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) fun buildFilterParams(account: Account): FilterByListParams = - FilterByListParams.Companion.create( - account.userProfile().pubkeyHex, - account.settings.defaultDiscoveryFollowList.value, + FilterByListParams.create( account.liveDiscoveryFollowLists.value, - account.flowHiddenUsers.value, + account.hiddenUsers.flow.value, ) protected open fun innerApplyFilter(collection: Collection): Set { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt new file mode 100644 index 000000000..bb5a3f60a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 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.nip51FollowSets + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeFollowSetsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + return when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterFollowSetsByAllCommunities(feedSettings, since) + is AllFollowsByOutboxTopNavPerRelayFilterSet -> filterFollowSetsByFollows(feedSettings, since) + is AuthorsByOutboxTopNavPerRelayFilterSet -> filterFollowSetsByAuthors(feedSettings, since) + is GlobalTopNavPerRelayFilterSet -> filterFollowSetsGlobal(feedSettings, since) + is HashtagTopNavPerRelayFilterSet -> filterFollowSetsByHashtag(feedSettings, since) + is LocationTopNavPerRelayFilterSet -> filterFollowSetsByGeohash(feedSettings, since) + is MutedAuthorsByOutboxTopNavPerRelayFilterSet -> filterFollowSetsByAuthors(feedSettings, since) + is SingleCommunityTopNavPerRelayFilterSet -> filterFollowSetsByCommunity(feedSettings, since) + else -> emptyList() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt new file mode 100644 index 000000000..21ec68b6c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2025 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.nip51FollowSets.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsAllCommunities +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.FollowListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten + +fun filterFollowSetsAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(FollowListEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("k" to listOf("5300"), "a" to communityList), + kinds = listOf(FollowListEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterFollowSetsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return communitySet.set.mapNotNull { + filterContentDVMsAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt new file mode 100644 index 000000000..d37e3e392 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2025 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.nip51FollowSets.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.FollowListEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten +import kotlin.collections.mapNotNull + +fun filterFollowSetsAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(FollowListEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterFollowSetsByAuthors( + authorSet: AuthorsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return authorSet.set.mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterFollowSetsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterFollowSetsByAuthors( + authorSet: MutedAuthorsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return authorSet.set.mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterFollowSetsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt new file mode 100644 index 000000000..7a0fb3f66 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2025 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.nip51FollowSets.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.FollowListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten + +fun filterFollowSetsByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to listOf(FollowListEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), + kinds = listOf(FollowListEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterFollowSetsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return communitySet.set.mapNotNull { + filterFollowSetsByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByFollows.kt index 129ae8bf3..60acbeabb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByFollows.kt @@ -20,29 +20,37 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip51Lists.FollowListEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatMap fun filterFollowSetsByFollows( - follows: Map>?, - since: Map?, -): List? { - if (follows != null && follows.isEmpty()) return null + followsSet: AllFollowsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (followsSet.set.isEmpty()) return emptyList() - return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(FollowListEvent.KIND), - limit = 300, - since = since, - ), - ), - ) + val defaultSince = TimeUtils.oneWeekAgo() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterFollowSetsAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterFollowSetsByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterFollowSetsByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterFollowSetsAllCommunities(relay, it, since) + }, + ).flatten() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt index dfe845e0c..1ab2287ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt @@ -20,26 +20,30 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.FollowListEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.mapNotNull fun filterFollowSetsByGeohash( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() - val geoHashes = hashToLoad.toList() + val geoHashes = geotags.sorted() return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = listOf(LongTextNoteEvent.KIND), + Filter( + kinds = listOf(FollowListEvent.KIND), tags = mapOf("g" to geoHashes), limit = 300, since = since, @@ -47,3 +51,24 @@ fun filterFollowSetsByGeohash( ), ) } + +fun filterFollowSetsByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return geoSet.set.mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterFollowSetsByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt index 7fc277b39..530015ce0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt @@ -20,30 +20,31 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip51Lists.FollowListEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.mapNotNull fun filterFollowSetsByHashtag( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long?, +): List { + if (hashtags.isEmpty()) return emptyList() - val hashtags = hashtagAlts(hashToLoad).toList() + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - LongTextNoteEvent.KIND, - ), + Filter( + kinds = listOf(FollowListEvent.KIND), tags = mapOf("t" to hashtags), limit = 300, since = since, @@ -51,3 +52,24 @@ fun filterFollowSetsByHashtag( ), ) } + +fun filterFollowSetsByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return hashSet.set.mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterFollowSetsByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterQuotesToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt similarity index 54% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterQuotesToNotes.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt index 8770b335b..ebc294348 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterQuotesToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt @@ -18,32 +18,33 @@ * 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.watchers +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip51Lists.FollowListEvent +import com.vitorpamplona.quartz.utils.TimeUtils -fun filterQuotesToNotes( - notes: List, - since: Map?, -): List? { - if (notes.isEmpty()) return null +fun filterFollowSetsGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (relays.set.isEmpty()) return emptyList() - return listOf( - TypedFilter( - types = EVENT_FINDER_TYPES, + val defaultSince = TimeUtils.oneWeekAgo() + + return relays.set.map { + val since = since?.get(it.key)?.time ?: defaultSince + RelayBasedFilter( + relay = it.key, filter = - SincePerRelayFilter( - kinds = listOf(TextNoteEvent.KIND), - tags = mapOf("q" to notes.map { it.idHex }.sorted()), + Filter( + kinds = listOf(FollowListEvent.KIND), + limit = 300, since = since, - // Max amount of "replies" to download on a specific event. - limit = 1000, ), - ), - ) + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt index 343780191..09660985b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt @@ -24,6 +24,10 @@ 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.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip51Lists.MuteListEvent @@ -43,8 +47,8 @@ open class DiscoverLiveFeedFilter( followList() == MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) override fun feed(): List { - val allChannelNotes = LocalCache.channels.mapNotNull { _, channel -> LocalCache.getNoteIfExists(channel.idHex) } - val allMessageNotes = LocalCache.channels.map { _, channel -> channel.notes.filter { key, it -> it.event is LiveActivitiesEvent } }.flatten() + val allChannelNotes = LocalCache.liveChatChannels.mapNotNull { _, channel -> LocalCache.getNoteIfExists(channel.idHex) } + val allMessageNotes = LocalCache.liveChatChannels.map { _, channel -> channel.notes.filter { key, it -> it.event is LiveActivitiesEvent } }.flatten() val notes = innerApplyFilter(allChannelNotes + allMessageNotes) @@ -55,11 +59,9 @@ open class DiscoverLiveFeedFilter( protected open fun innerApplyFilter(collection: Collection): Set { val filterParams = - FilterByListParams.Companion.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultDiscoveryFollowList.value, + FilterByListParams.create( followLists = account.liveDiscoveryFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) return collection.filterTo(HashSet()) { @@ -69,8 +71,18 @@ open class DiscoverLiveFeedFilter( } override fun sort(collection: Set): List { + val topFilter = account.liveDiscoveryFollowLists.value + val discoveryTopFilterAuthors = + when (topFilter) { + is AuthorsByOutboxTopNavFilter -> topFilter.authors + is MutedAuthorsByOutboxTopNavFilter -> topFilter.authors + is AllFollowsByOutboxTopNavFilter -> topFilter.authors + is SingleCommunityTopNavFilter -> topFilter.authors + else -> null + } + val followingKeySet = - account.liveDiscoveryFollowLists.value?.authors ?: account.liveKind3Follows.value.authors + discoveryTopFilterAuthors ?: account.kind3FollowList.flow.value.authors val counter = ParticipantListBuilder() val participantCounts = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt index 41dfa2f86..775ca02ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/LiveActivityCard.kt @@ -45,6 +45,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.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.navigation.INav @@ -222,9 +226,16 @@ fun LoadParticipants( val hostsAuthor = hosts + (baseNote.author?.let { listOf(it) } ?: emptyList()) + val topFilter = accountViewModel.account.liveDiscoveryFollowLists.value + val followingKeySet = - accountViewModel.account.liveDiscoveryFollowLists.value - ?.authors + when (topFilter) { + is AuthorsByOutboxTopNavFilter -> topFilter.authors + is MutedAuthorsByOutboxTopNavFilter -> topFilter.authors + is AllFollowsByOutboxTopNavFilter -> topFilter.authors + is SingleCommunityTopNavFilter -> topFilter.authors + else -> emptySet() + } val allParticipants = ParticipantListBuilder() @@ -233,7 +244,7 @@ fun LoadParticipants( val newParticipantUsers = if (followingKeySet == null) { - val allFollows = accountViewModel.account.liveKind3Follows.value.authors + val allFollows = accountViewModel.account.kind3FollowList.flow.value.authors val followingParticipants = ParticipantListBuilder() .followsThatParticipateOn(baseNote, allFollows) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt new file mode 100644 index 000000000..13acbd03e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 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.nip53LiveActivities + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeLiveActivitiesFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + return when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterLiveActivitiesByAllCommunities(feedSettings, since) + is AllFollowsByOutboxTopNavPerRelayFilterSet -> filterLiveActivitiesByFollows(feedSettings, since) + is AuthorsByOutboxTopNavPerRelayFilterSet -> filterLiveActivitiesByAuthors(feedSettings, since) + is GlobalTopNavPerRelayFilterSet -> filterLiveActivitiesGlobal(feedSettings, since) + is HashtagTopNavPerRelayFilterSet -> filterLiveActivitiesByHashtag(feedSettings, since) + is LocationTopNavPerRelayFilterSet -> filterLiveActivitiesByGeohash(feedSettings, since) + is MutedAuthorsByOutboxTopNavPerRelayFilterSet -> filterLiveActivitiesByAuthors(feedSettings, since) + is SingleCommunityTopNavPerRelayFilterSet -> filterLiveActivitiesByCommunity(feedSettings, since) + else -> emptyList() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt new file mode 100644 index 000000000..c139cea3d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2025 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.nip53LiveActivities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsAllCommunities +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +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.utils.TimeUtils +import kotlin.collections.flatten + +fun filterLiveActivitiesAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(LiveActivitiesChatMessageEvent.KIND.toString(), LiveActivitiesEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("k" to listOf("5300"), "a" to communityList), + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterLiveActivitiesByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return communitySet.set.mapNotNull { + filterContentDVMsAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt new file mode 100644 index 000000000..84225a667 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt @@ -0,0 +1,107 @@ +/** + * Copyright (c) 2025 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.nip53LiveActivities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten +import kotlin.collections.mapNotNull + +fun filterLiveActivitiesAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), + limit = 300, + since = since, + ), + ), + // authors are participating in the live event. + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("p" to authorList), + kinds = listOf(LiveActivitiesEvent.KIND), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterLiveActivitiesByAuthors( + authorSet: AuthorsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return authorSet.set.mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterLiveActivitiesAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterLiveActivitiesByAuthors( + authorSet: MutedAuthorsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return authorSet.set.mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterLiveActivitiesAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt new file mode 100644 index 000000000..382c0d633 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2025 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.nip53LiveActivities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +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.utils.TimeUtils +import kotlin.collections.flatten + +fun filterLiveActivitiesByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to listOf(LiveActivitiesChatMessageEvent.KIND.toString(), LiveActivitiesEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterLiveActivitiesByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return communitySet.set.mapNotNull { + filterLiveActivitiesByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByFollows.kt index f69c59490..40ee82d9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByFollows.kt @@ -20,48 +20,37 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatMap fun filterLiveActivitiesByFollows( - follows: Map>?, - followKeys: Set?, - since: Map?, -): List? { - if (follows != null && follows.isEmpty()) return null + followsSet: AllFollowsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (followsSet.set.isEmpty()) return emptyList() - return listOfNotNull( - TypedFilter( - types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = - listOf( - LiveActivitiesChatMessageEvent.KIND, - LiveActivitiesEvent.KIND, - ), - limit = 300, - since = since, - ), - ), - followKeys?.let { - TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - SincePerRelayFilter( - tags = mapOf("p" to it.toList()), - kinds = listOf(LiveActivitiesEvent.KIND), - limit = 100, - since = since, - ), - ) - }, - ) + val defaultSince = TimeUtils.oneWeekAgo() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterLiveActivitiesAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterLiveActivitiesByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterLiveActivitiesByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterLiveActivitiesAllCommunities(relay, it, since) + }, + ).flatten() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt index 8ad895543..8a9470a0c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt @@ -20,31 +20,31 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.mapNotNull fun filterLiveActivitiesByGeohash( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() - val geoHashes = hashToLoad.toList() + val geoHashes = geotags.sorted() return listOf( - TypedFilter( - types = setOf(FeedType.GLOBAL), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - LiveActivitiesChatMessageEvent.KIND, - LiveActivitiesEvent.KIND, - ), + Filter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), tags = mapOf("g" to geoHashes), limit = 300, since = since, @@ -52,3 +52,24 @@ fun filterLiveActivitiesByGeohash( ), ) } + +fun filterLiveActivitiesByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return geoSet.set.mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterLiveActivitiesByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt index 3b67b868c..32bd409ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt @@ -20,32 +20,32 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.mapNotNull fun filterLiveActivitiesByHashtag( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long?, +): List { + if (hashtags.isEmpty()) return emptyList() - val hashtags = hashtagAlts(hashToLoad).toList() + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() return listOf( - TypedFilter( - types = setOf(FeedType.GLOBAL), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - LiveActivitiesChatMessageEvent.KIND, - LiveActivitiesEvent.KIND, - ), + Filter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), tags = mapOf("t" to hashtags), limit = 300, since = since, @@ -53,3 +53,24 @@ fun filterLiveActivitiesByHashtag( ), ) } + +fun filterLiveActivitiesByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return hashSet.set.mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterLiveActivitiesByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterUserMetadataByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt similarity index 50% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterUserMetadataByFollows.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt index b9f3b5c3a..5b9c7edd5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterUserMetadataByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt @@ -18,35 +18,34 @@ * 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.nip65Follows +package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.utils.TimeUtils -fun filterUserMetadataByFollows( - follows: Map>?, - since: Map?, -): List { - if (follows != null && follows.isEmpty()) return emptyList() +fun filterLiveActivitiesGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (relays.set.isEmpty()) return emptyList() - return listOf( - TypedFilter( - types = setOf(if (follows != null) FeedType.FOLLOWS else FeedType.GLOBAL), + val defaultSince = TimeUtils.oneWeekAgo() + + return relays.set.map { + val since = since?.get(it.key)?.time ?: defaultSince + RelayBasedFilter( + relay = it.key, filter = - SinceAuthorPerRelayFilter( - kinds = - listOf( - MetadataEvent.KIND, - AdvertisedRelayListEvent.KIND, - ), - authors = follows, + Filter( + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND), + limit = 300, since = since, ), - ), - ) + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt index 460f8d3ba..464b93512 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt @@ -47,6 +47,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.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.ui.navigation.INav @@ -197,15 +201,23 @@ fun LoadModerators( } } - val followingKeySet = - accountViewModel.account.liveDiscoveryFollowLists.value - ?.authors + val topFilter = accountViewModel.account.liveDiscoveryFollowLists.value + val discoveryTopFilterAuthors = + when (topFilter) { + is AuthorsByOutboxTopNavFilter -> topFilter.authors + is MutedAuthorsByOutboxTopNavFilter -> topFilter.authors + is AllFollowsByOutboxTopNavFilter -> topFilter.authors + is SingleCommunityTopNavFilter -> topFilter.authors + else -> null + } + + val followingKeySet = discoveryTopFilterAuthors val allParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts) val newParticipantUsers = if (followingKeySet == null) { - val allFollows = accountViewModel.account.liveKind3Follows.value.authors + val allFollows = accountViewModel.account.kind3FollowList.flow.value.authors val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/DiscoverCommunityFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/DiscoverCommunityFeedFilter.kt index c4b3ad02e..0ee01359d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/DiscoverCommunityFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/DiscoverCommunityFeedFilter.kt @@ -25,6 +25,7 @@ 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.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent @@ -44,18 +45,16 @@ open class DiscoverCommunityFeedFilter( override fun feed(): List { val filterParams = - FilterByListParams.Companion.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultDiscoveryFollowList.value, + FilterByListParams.create( followLists = account.liveDiscoveryFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) // Here we only need to look for CommunityDefinition Events val notes = LocalCache.addressables.mapNotNullIntoSet { key, note -> val noteEvent = note.event - if (noteEvent == null && shouldInclude(Address.Companion.parse(key), filterParams)) { + if (noteEvent == null && shouldInclude(key, filterParams, note.relays)) { // send unloaded communities to the screen note } else if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent)) { @@ -73,11 +72,9 @@ 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.Companion.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultDiscoveryFollowList.value, + FilterByListParams.create( followLists = account.liveDiscoveryFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) return collection @@ -91,7 +88,7 @@ open class DiscoverCommunityFeedFilter( val definitionNote = LocalCache.getOrCreateAddressableNote(it) val definitionEvent = definitionNote.event - if (definitionEvent == null && shouldInclude(it, filterParams)) { + if (definitionEvent == null && shouldInclude(it, filterParams, definitionNote.relays)) { definitionNote } else if (definitionEvent is CommunityDefinitionEvent && filterParams.match(definitionEvent)) { definitionNote @@ -109,7 +106,8 @@ open class DiscoverCommunityFeedFilter( private fun shouldInclude( aTag: Address?, params: FilterByListParams, - ) = aTag != null && aTag.kind == CommunityDefinitionEvent.KIND && params.match(aTag) + comingFrom: List = emptyList(), + ) = aTag != null && aTag.kind == CommunityDefinitionEvent.KIND && params.match(aTag, comingFrom) override fun sort(collection: Set): List { val lastNote = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt new file mode 100644 index 000000000..97576f15e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 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.nip72Communities + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeCommunitiesFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + return when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterCommunitiesByAllCommunities(feedSettings, since) + is AllFollowsByOutboxTopNavPerRelayFilterSet -> filterCommunitiesByFollows(feedSettings, since) + is AuthorsByOutboxTopNavPerRelayFilterSet -> filterCommunitiesByAuthors(feedSettings, since) + is GlobalTopNavPerRelayFilterSet -> filterCommunitiesGlobal(feedSettings, since) + is HashtagTopNavPerRelayFilterSet -> filterCommunitiesByHashtag(feedSettings, since) + is LocationTopNavPerRelayFilterSet -> filterCommunitiesByGeohash(feedSettings, since) + is MutedAuthorsByOutboxTopNavPerRelayFilterSet -> filterCommunitiesByAuthors(feedSettings, since) + is SingleCommunityTopNavPerRelayFilterSet -> filterCommunitiesByCommunity(feedSettings, since) + else -> emptyList() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt new file mode 100644 index 000000000..3dff4e38c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 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.nip72Communities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten + +fun filterCommunitiesAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + ids = communityList, + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("a" to communityList), + kinds = listOf(ClassifiedsEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterCommunitiesByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return communitySet.set.mapNotNull { + filterCommunitiesAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt new file mode 100644 index 000000000..b1dab2df3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2025 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.nip72Communities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten +import kotlin.collections.mapNotNull + +fun filterCommunitiesAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterCommunitiesByAuthors( + authorSet: AuthorsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return authorSet.set.mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterCommunitiesAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterCommunitiesByAuthors( + authorSet: MutedAuthorsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return authorSet.set.mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterCommunitiesAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt new file mode 100644 index 000000000..4a1bc7b7e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2025 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.nip72Communities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten + +fun filterClassifiedsByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = listOf(CommunityDefinitionEvent.KIND), + ids = listOf(community), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterCommunitiesByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return communitySet.set.mapNotNull { + filterClassifiedsByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByFollows.kt index 54bfbb8b9..014ac2d36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByFollows.kt @@ -20,34 +20,37 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatMap fun filterCommunitiesByFollows( - follows: Map>?, - since: Map?, -): List? { - if (follows != null && follows.isEmpty()) return null + followsSet: AllFollowsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (followsSet.set.isEmpty()) return emptyList() - return listOf( - 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, - ), - ), - ) + val defaultSince = TimeUtils.oneWeekAgo() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterCommunitiesAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterCommunitiesByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterCommunitiesByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterCommunitiesAllCommunities(relay, it, since) + }, + ).flatten() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt new file mode 100644 index 000000000..338fb283f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2025 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.nip72Communities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.mapNotNull + +fun filterCommunitiesByGeohash( + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() + + val geoHashes = geotags.sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), + tags = mapOf("g" to geoHashes), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterCommunitiesByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return geoSet.set.mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterCommunitiesByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt new file mode 100644 index 000000000..0a6aa4afd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2025 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.nip72Communities.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.mapNotNull + +fun filterCommunitiesByHashtag( + relay: NormalizedRelayUrl, + hashtags: Set?, + since: Long?, +): List { + if (hashtags == null || hashtags.isEmpty()) return emptyList() + + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), + tags = mapOf("t" to hashtags), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterCommunitiesByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return hashSet.set.mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterCommunitiesByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunityPostsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt similarity index 57% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunityPostsByGeohash.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt index 11dad756f..68e42b47a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunityPostsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt @@ -20,35 +20,34 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.utils.TimeUtils -fun filterCommunityPostsByGeohash( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null +fun filterCommunitiesGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (relays.set.isEmpty()) return emptyList() - val geoHashes = hashToLoad.toList() + val defaultSince = TimeUtils.oneWeekAgo() - return listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = - SincePerRelayFilter( - kinds = - listOf( - CommunityDefinitionEvent.KIND, - CommunityPostApprovalEvent.KIND, - ), - tags = mapOf("g" to geoHashes), - limit = 300, - since = since, - ), - ), - ) + return relays.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + listOf( + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), + limit = 300, + since = since, + ), + ), + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt index d4aaa581b..f790a49ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt @@ -56,11 +56,9 @@ open class DiscoverNIP89FeedFilter( override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) fun buildFilterParams(account: Account): FilterByListParams = - FilterByListParams.Companion.create( - account.userProfile().pubkeyHex, - account.settings.defaultDiscoveryFollowList.value, + FilterByListParams.create( account.liveDiscoveryFollowLists.value, - account.flowHiddenUsers.value, + account.hiddenUsers.flow.value, ) fun acceptDVM(note: Note): Boolean { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt new file mode 100644 index 000000000..3438e6ef2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 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.nip90DVMs + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeContentDVMsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + return when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterContentDVMsByAllCommunities(feedSettings, since) + is AllFollowsByOutboxTopNavPerRelayFilterSet -> filterContentDVMsByFollows(feedSettings, since) + is AuthorsByOutboxTopNavPerRelayFilterSet -> filterContentDVMsByAuthors(feedSettings, since) + is GlobalTopNavPerRelayFilterSet -> filterContentDVMsGlobal(feedSettings, since) + is HashtagTopNavPerRelayFilterSet -> filterContentDVMsByHashtag(feedSettings, since) + is LocationTopNavPerRelayFilterSet -> filterContentDVMsByGeohash(feedSettings, since) + is MutedAuthorsByOutboxTopNavPerRelayFilterSet -> filterContentDVMsByAuthors(feedSettings, since) + is SingleCommunityTopNavPerRelayFilterSet -> filterContentDVMsByCommunity(feedSettings, since) + else -> emptyList() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt new file mode 100644 index 000000000..04b096309 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2025 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.nip90DVMs.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten + +fun filterContentDVMsAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(AppDefinitionEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("k" to listOf("5300"), "a" to communityList), + kinds = listOf(AppDefinitionEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterContentDVMsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return communitySet.set.mapNotNull { + filterContentDVMsAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt new file mode 100644 index 000000000..2c7cfa0ae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2025 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.nip90DVMs.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten +import kotlin.collections.mapNotNull + +fun filterContentDVMsAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(AppDefinitionEvent.KIND), + tags = mapOf("k" to listOf("5300")), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterContentDVMsByAuthors( + authorSet: AuthorsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return authorSet.set.mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterContentDVMsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterContentDVMsByAuthors( + authorSet: MutedAuthorsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return authorSet.set.mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterContentDVMsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt new file mode 100644 index 000000000..8c0e586d1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2025 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.nip90DVMs.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten + +fun filterContentDVMsByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to listOf(AppDefinitionEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), + kinds = listOf(AppDefinitionEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterContentDVMsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return communitySet.set.mapNotNull { + filterContentDVMsByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByFollows.kt index 83dce4993..6cf430625 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByFollows.kt @@ -20,29 +20,37 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatMap fun filterContentDVMsByFollows( - follows: Map>?, - since: Map?, -): List? { - if (follows != null && follows.isEmpty()) return null + followsSet: AllFollowsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (followsSet.set.isEmpty()) return emptyList() - return listOf( - TypedFilter( - types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS), - filter = - SincePerRelayFilter( - kinds = listOf(AppDefinitionEvent.KIND), - limit = 300, - tags = mapOf("k" to listOf("5300")), - since = since, - ), - ), - ) + val defaultSince = TimeUtils.oneWeekAgo() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterContentDVMsAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterContentDVMsByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterContentDVMsByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterContentDVMsAllCommunities(relay, it, since) + }, + ).flatten() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt index 9b0475900..0f8a6e7de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt @@ -20,25 +20,29 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.mapNotNull fun filterContentDVMsByGeohash( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() - val geoHashes = hashToLoad.toList() + val geoHashes = geotags.sorted() return listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( kinds = listOf(AppDefinitionEvent.KIND), tags = mapOf("k" to listOf("5300"), "g" to geoHashes), limit = 300, @@ -47,3 +51,24 @@ fun filterContentDVMsByGeohash( ), ) } + +fun filterContentDVMsByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return geoSet.set.mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterContentDVMsByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt index 076b9d6d3..1a486d011 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt @@ -20,26 +20,30 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.mapNotNull fun filterContentDVMsByHashtag( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long?, +): List { + if (hashtags.isEmpty()) return emptyList() - val hashtags = hashtagAlts(hashToLoad).toList() + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() return listOf( - TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( kinds = listOf(AppDefinitionEvent.KIND), tags = mapOf("k" to listOf("5300"), "t" to hashtags), limit = 300, @@ -48,3 +52,24 @@ fun filterContentDVMsByHashtag( ), ) } + +fun filterContentDVMsByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return hashSet.set.mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterContentDVMsByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt new file mode 100644 index 000000000..2f44bd199 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2025 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.nip90DVMs.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +fun filterContentDVMsGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (relays.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return relays.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + listOf( + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(AppDefinitionEvent.KIND), + tags = mapOf("k" to listOf("5300")), + limit = 300, + since = since, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt index ecc5fb997..2f62564a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt @@ -55,11 +55,9 @@ open class DiscoverMarketplaceFeedFilter( override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) fun buildFilterParams(account: Account): FilterByListParams = - FilterByListParams.Companion.create( - account.userProfile().pubkeyHex, - account.settings.defaultDiscoveryFollowList.value, + FilterByListParams.create( account.liveDiscoveryFollowLists.value, - account.flowHiddenUsers.value, + account.hiddenUsers.flow.value, ) protected open fun innerApplyFilter(collection: Collection): Set { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index 9466877f0..2332b3f41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -60,7 +60,7 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialogEasy +import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialog import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia @@ -96,7 +96,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.launch @@ -212,10 +211,10 @@ fun NewProductScreen( }, ) { pad -> if (showRelaysDialog) { - RelaySelectionDialogEasy( + RelaySelectionDialog( preSelectedList = postViewModel.relayList ?: persistentListOf(), onClose = { showRelaysDialog = false }, - onPost = { postViewModel.relayList = it.map { it.url }.toImmutableList() }, + onPost = { postViewModel.relayList = it }, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index d9fce20a7..56ce892d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash @@ -164,7 +165,7 @@ open class NewProductViewModel : var wantsZapraiser by mutableStateOf(false) override val zapRaiserAmount = mutableStateOf(null) - var relayList by mutableStateOf?>(null) + var relayList by mutableStateOf?>(null) fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress() @@ -474,17 +475,7 @@ open class NewProductViewModel : fun reloadRelaySet() { val account = accountViewModel?.account ?: return - val nip65 = account.normalizedNIP65WriteRelayList.value - val private = account.normalizedPrivateOutBoxRelaySet.value - val local = account.settings.localRelayServers - - relayList = - if (nip65.isEmpty()) { - account.activeWriteRelays().map { it.url }.toImmutableList() - } else { - val combined: Set = (nip65 + private + local) - combined.toImmutableList() - } + relayList = account.outboxRelays.flow.value.toImmutableList() } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt new file mode 100644 index 000000000..b4047919a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025 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.nip99Classifieds + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makeClassifiedsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + return when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterClassifiedsByAllCommunities(feedSettings, since) + is AllFollowsByOutboxTopNavPerRelayFilterSet -> filterClassifiedsByFollows(feedSettings, since) + is AuthorsByOutboxTopNavPerRelayFilterSet -> filterClassifiedsByAuthors(feedSettings, since) + is GlobalTopNavPerRelayFilterSet -> filterClassifiedsGlobal(feedSettings, since) + is HashtagTopNavPerRelayFilterSet -> filterClassifiedsByHashtag(feedSettings, since) + is LocationTopNavPerRelayFilterSet -> filterClassifiedsByGeohash(feedSettings, since) + is MutedAuthorsByOutboxTopNavPerRelayFilterSet -> filterClassifiedsByAuthors(feedSettings, since) + is SingleCommunityTopNavPerRelayFilterSet -> filterClassifiedsByCommunity(feedSettings, since) + else -> emptyList() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt new file mode 100644 index 000000000..d143e23bc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2025 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.nip99Classifieds.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten + +fun filterClassifiedsAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(ClassifiedsEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("a" to communityList), + kinds = listOf(ClassifiedsEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterClassifiedsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return communitySet.set.mapNotNull { + filterClassifiedsAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt new file mode 100644 index 000000000..c6168c634 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2025 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.nip99Classifieds.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten +import kotlin.collections.mapNotNull + +fun filterClassifiedsAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(ClassifiedsEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterClassifiedsByAuthors( + authorSet: AuthorsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return authorSet.set.mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterClassifiedsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterClassifiedsByAuthors( + authorSet: MutedAuthorsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return authorSet.set.mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterClassifiedsAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt new file mode 100644 index 000000000..7b5d64e70 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2025 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.nip99Classifieds.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten + +fun filterClassifiedsByCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to listOf(ClassifiedsEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("a" to listOf(community)), + kinds = listOf(ClassifiedsEvent.KIND), + limit = 300, + since = since, + ), + ), + ) +} + +fun filterClassifiedsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return communitySet.set.mapNotNull { + filterClassifiedsByCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByFollows.kt index 38c5d3c8f..0a6924668 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByFollows.kt @@ -20,29 +20,37 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten fun filterClassifiedsByFollows( - follows: Map>?, - since: Map?, -): List? { - if (follows != null && follows.isEmpty()) return null + followsSet: AllFollowsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (followsSet.set.isEmpty()) return emptyList() - return listOf( - TypedFilter( - types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(ClassifiedsEvent.KIND), - limit = 300, - since = since, - ), - ), - ) + val defaultSince = TimeUtils.oneWeekAgo() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterClassifiedsAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterClassifiedsByGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterClassifiedsByHashtag(relay, it, since) + }, + it.value.communities?.let { + filterClassifiedsAllCommunities(relay, it, since) + }, + ).flatten() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt index 9273b0028..f43b66121 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt @@ -20,25 +20,29 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.mapNotNull fun filterClassifiedsByGeohash( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + if (geotags.isEmpty()) return emptyList() - val geoHashes = hashToLoad.toList() + val geoHashes = geotags.sorted() return listOf( - TypedFilter( - types = setOf(FeedType.GLOBAL), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( kinds = listOf(ClassifiedsEvent.KIND), tags = mapOf("g" to geoHashes), limit = 300, @@ -47,3 +51,24 @@ fun filterClassifiedsByGeohash( ), ) } + +fun filterClassifiedsByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return geoSet.set.mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterClassifiedsByGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt index a82f424ea..5efa7423d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt @@ -20,26 +20,30 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.mapNotNull fun filterClassifiedsByHashtag( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + hashtags: Set?, + since: Long?, +): List? { + if (hashtags == null || hashtags.isEmpty()) return null - val hashtags = hashtagAlts(hashToLoad).toList() + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() return listOf( - TypedFilter( - types = setOf(FeedType.GLOBAL), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( + Filter( kinds = listOf(ClassifiedsEvent.KIND), tags = mapOf("t" to hashtags), limit = 300, @@ -48,3 +52,24 @@ fun filterClassifiedsByHashtag( ), ) } + +fun filterClassifiedsByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return hashSet.set.mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterClassifiedsByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt new file mode 100644 index 000000000..57c0f4839 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 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.nip99Classifieds.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +fun filterClassifiedsGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (relays.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return relays.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + listOf( + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(ClassifiedsEvent.KIND), + limit = 300, + since = since, + ), + ), + ) + } +} 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 cd8da2f71..4f66b17bd 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 @@ -151,7 +151,7 @@ fun DvmContentDiscoveryScreen( } val onRefresh = { - accountViewModel.requestDVMContentDiscovery(noteAuthor.pubkeyHex) { + accountViewModel.requestDVMContentDiscovery(noteAuthor) { requestEventID = it } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt index b5ab39471..98fc314c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt @@ -73,10 +73,8 @@ open class NIP90ContentDiscoveryResponseFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - account.userProfile().pubkeyHex, - account.settings.defaultDiscoveryFollowList.value, account.liveDiscoveryFollowLists.value, - account.flowHiddenUsers.value, + account.hiddenUsers.flow.value, ) protected open fun innerApplyFilter(collection: Collection): Set { 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 05bedb1b8..aff0303d2 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,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash +import android.annotation.SuppressLint import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text @@ -33,6 +34,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse 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.Route import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView @@ -44,25 +46,31 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton @Composable fun GeoHashScreen( - tag: String?, + tag: Route.Geohash, accountViewModel: AccountViewModel, nav: INav, ) { - if (tag == null) return + if (tag.geohash.isEmpty()) return PrepareViewModelsGeoHashScreen(tag, accountViewModel, nav) } +@SuppressLint("StateFlowValueCalledInComposition") @Composable fun PrepareViewModelsGeoHashScreen( - tag: String, + tag: Route.Geohash, accountViewModel: AccountViewModel, nav: INav, ) { val geohashViewModel: GeoHashFeedViewModel = viewModel( - key = tag + "GeoHashFeedViewModel", - factory = GeoHashFeedViewModel.Factory(tag, accountViewModel.account), + key = tag.geohash + "GeoHashFeedViewModel", + factory = + GeoHashFeedViewModel.Factory( + tag.geohash, + accountViewModel.account.followOutboxes.flow.value, + accountViewModel.account, + ), ) GeoHashScreen(tag, geohashViewModel, accountViewModel, nav) @@ -70,27 +78,27 @@ fun PrepareViewModelsGeoHashScreen( @Composable fun GeoHashScreen( - tag: String, + tag: Route.Geohash, feedViewModel: GeoHashFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { WatchLifecycleAndUpdateModel(feedViewModel) - GeoHashFilterAssemblerSubscription(tag, accountViewModel.dataSources().geohashes) + GeoHashFilterAssemblerSubscription(tag, accountViewModel) DisappearingScaffold( isInvertedLayout = false, topBar = { TopBarExtensibleWithBackButton( title = { - DisplayGeoTagHeader(tag, Modifier.weight(1f)) - GeoHashActionOptions(tag, accountViewModel) + DisplayGeoTagHeader(tag.geohash, Modifier.weight(1f)) + GeoHashActionOptions(tag.geohash, accountViewModel) }, popBack = nav::popBack, ) }, floatingButton = { - NewGeoPostButton(tag, accountViewModel, nav) + NewGeoPostButton(tag.geohash, accountViewModel, nav) }, accountViewModel = accountViewModel, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt index cf5e46e1e..1ffdfaefd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt @@ -28,6 +28,7 @@ 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.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -39,6 +40,7 @@ import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId class GeoHashFeedFilter( val tag: String, + val relays: Set, val account: Account, val cache: LocalCache, ) : AdditiveFeedFilter() { @@ -62,7 +64,7 @@ class GeoHashFeedFilter( geoTag: String, ): Boolean = (acceptableViaHashtag(it.event, geoTag) || acceptableViaScope(it.event, geoTag)) && - !it.isHiddenFor(account.flowHiddenUsers.value) && + !it.isHiddenFor(account.hiddenUsers.flow.value) && account.isAcceptable(it) fun acceptableViaHashtag( @@ -77,8 +79,7 @@ class GeoHashFeedFilter( event is PrivateDmEvent || event is PollNoteEvent || event is AudioHeaderEvent - ) && - event.isTaggedGeoHash(geohash) == true + ) && event.isTaggedGeoHash(geohash) fun acceptableViaScope( event: Event?, 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 index 768dc650a..bd754f7a2 100644 --- 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 @@ -26,20 +26,23 @@ import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.FeedViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable class GeoHashFeedViewModel( val geohash: String, + val relays: Set, val account: Account, ) : FeedViewModel( - GeoHashFeedFilter(geohash, account, LocalCache), + GeoHashFeedFilter(geohash, relays, account, LocalCache), ) { @Suppress("UNCHECKED_CAST") class Factory( val geohash: String, + val relays: Set, val account: Account, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = GeoHashFeedViewModel(geohash, account) as T + override fun create(modelClass: Class): T = GeoHashFeedViewModel(geohash, relays, account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt index ec91356dc..8df6e09d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.CommentKinds import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -36,44 +37,51 @@ import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +val PostsByGeohashKinds = + listOf( + TextNoteEvent.KIND, + ChannelMessageEvent.KIND, + LongTextNoteEvent.KIND, + PollNoteEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + ) + fun filterPostsByGeohash( geohash: String, - since: Map?, -): List = - listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("g" to listOf(geohash)), - kinds = - listOf( - TextNoteEvent.KIND, - ChannelMessageEvent.KIND, - LongTextNoteEvent.KIND, - PollNoteEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - WikiNoteEvent.KIND, - CommentEvent.KIND, - ), - limit = 200, - since = since, - ), - ), - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("I" to listOf(GeohashId.toScope(geohash))), - kinds = - listOf( - CommentEvent.KIND, - ), - limit = 200, - since = since, - ), - ), - ) + relays: Set, + since: SincePerRelayMap?, +): List { + val geohashesToFollowMap = mapOf("g" to listOf(geohash)) + val geohashesScoreMap = mapOf("I" to listOf(GeohashId.toScope(geohash))) + + return relays.flatMap { relay -> + val since = since?.get(relay)?.time + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = geohashesToFollowMap, + kinds = PostsByGeohashKinds, + limit = 200, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = geohashesScoreMap, + kinds = CommentKinds, + limit = 200, + since = since, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFeedFilterSubAssembler.kt index c8cab8525..bd4d56924 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFeedFilterSubAssembler.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class GeoHashFeedFilterSubAssembler( client: NostrClient, @@ -31,8 +31,8 @@ class GeoHashFeedFilterSubAssembler( ) : PerUniqueIdEoseManager(client, allKeys) { override fun updateFilter( key: GeohashQueryState, - since: Map?, - ): List? = filterPostsByGeohash(key.geohash, since) + since: SincePerRelayMap?, + ): List? = filterPostsByGeohash(key.geohash, key.relays, since) /** * Only one key per hashtag. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt index 3764b2fb5..088fbe0da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/GeoHashFilterAssembler.kt @@ -22,11 +22,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl // This allows multiple screen to be listening to tags, even the same tag class GeohashQueryState( val geohash: String, + val relays: Set, ) { val lowercaseGeohash = geohash.lowercase() } 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 index 62da067bb..27b70f85a 100644 --- 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 @@ -20,21 +20,25 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource +import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +@SuppressLint("StateFlowValueCalledInComposition") @Composable fun GeoHashFilterAssemblerSubscription( - tag: String, - dataSource: GeoHashFilterAssembler, + tag: Route.Geohash, + accountViewModel: AccountViewModel, ) { // different screens get different states // even if they are tracking the same tag. val state = remember(tag) { - GeohashQueryState(tag) + GeohashQueryState(tag.geohash, accountViewModel.account.followOutboxes.flow.value) } - KeyDataSourceSubscription(state, dataSource) + KeyDataSourceSubscription(state, accountViewModel.dataSources().geohashes) } 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 fb306881a..7a8733918 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag +import android.annotation.SuppressLint import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -38,6 +39,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse 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.Route import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -49,27 +51,29 @@ import com.vitorpamplona.amethyst.ui.theme.StdPadding @Composable fun HashtagScreen( - tag: String?, + tag: Route.Hashtag, accountViewModel: AccountViewModel, nav: INav, ) { - if (tag == null) return + if (tag.hashtag.isEmpty()) return PrepareViewModelsHashtagScreen(tag, accountViewModel, nav) } +@SuppressLint("StateFlowValueCalledInComposition") @Composable fun PrepareViewModelsHashtagScreen( - tag: String, + tag: Route.Hashtag, accountViewModel: AccountViewModel, nav: INav, ) { val hashtagFeedViewModel: HashtagFeedViewModel = viewModel( - key = tag + "HashtagFeedViewModel", + key = tag.hashtag + "HashtagFeedViewModel", factory = HashtagFeedViewModel.Factory( - tag, + tag.hashtag, + accountViewModel.account.followOutboxes.flow.value, accountViewModel.account, ), ) @@ -79,27 +83,27 @@ fun PrepareViewModelsHashtagScreen( @Composable fun HashtagScreen( - tag: String, + tag: Route.Hashtag, feedViewModel: HashtagFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { WatchLifecycleAndUpdateModel(feedViewModel) - HashtagFilterAssemblerSubscription(tag, accountViewModel.dataSources().hashtags) + HashtagFilterAssemblerSubscription(tag, accountViewModel) DisappearingScaffold( isInvertedLayout = false, topBar = { TopBarExtensibleWithBackButton( title = { - Text("#$tag", modifier = Modifier.weight(1f)) - HashtagActionOptions(tag, accountViewModel) + Text("#${tag.hashtag}", modifier = Modifier.weight(1f)) + HashtagActionOptions(tag.hashtag, accountViewModel) }, popBack = nav::popBack, ) }, floatingButton = { - NewHashtagPostButton(tag, accountViewModel, nav) + NewHashtagPostButton(tag.hashtag, accountViewModel, nav) }, accountViewModel = accountViewModel, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index 265c8bf3f..be5a8f483 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt @@ -28,6 +28,7 @@ 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.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -41,6 +42,7 @@ import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId class HashtagFeedFilter( val tag: String, + val relays: Set, val account: Account, val cache: LocalCache, ) : AdditiveFeedFilter() { @@ -64,7 +66,7 @@ class HashtagFeedFilter( hashTag: String, ): Boolean = (acceptableViaHashtag(it.event, hashTag) || acceptableViaScope(it.event, hashTag)) && - !it.isHiddenFor(account.flowHiddenUsers.value) && + !it.isHiddenFor(account.hiddenUsers.flow.value) && account.isAcceptable(it) fun acceptableViaHashtag( 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 index 07a5c3cac..b1b487941 100644 --- 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 @@ -26,20 +26,23 @@ import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.FeedViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable class HashtagFeedViewModel( val hashtag: String, + val relays: Set, val account: Account, ) : FeedViewModel( - HashtagFeedFilter(hashtag, account, LocalCache), + HashtagFeedFilter(hashtag, relays, account, LocalCache), ) { @Suppress("UNCHECKED_CAST") class Factory( val hashtag: String, + val relays: Set, val account: Account, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = HashtagFeedViewModel(hashtag, account) as T + override fun create(modelClass: Class): T = HashtagFeedViewModel(hashtag, relays, account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt index d5983195d..5b0c37659 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt @@ -20,14 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.CommentKinds import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent @@ -39,58 +40,67 @@ import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +val PostsByHashtagsKinds = + listOf( + TextNoteEvent.KIND, + ChannelMessageEvent.KIND, + LongTextNoteEvent.KIND, + PollNoteEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + ) + +val PostsByHashtagKinds2 = + listOf( + InteractiveStorySceneEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + ) + fun filterPostsByHashtags( hashtag: String, - since: Map?, -): List { - val hashtagsToFollow = hashtagAlts(hashtag).toList() + relays: Set, + since: SincePerRelayMap?, +): List { + val hashtagsToFollowMap = mapOf("t" to hashtagAlts(hashtag).sorted()) + val hashtagScoreMap = mapOf("I" to listOf(HashtagId.toScope(hashtag))) - return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("t" to hashtagsToFollow), - kinds = - listOf( - TextNoteEvent.KIND, - ChannelMessageEvent.KIND, - LongTextNoteEvent.KIND, - PollNoteEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - WikiNoteEvent.KIND, - CommentEvent.KIND, - ), - limit = 400, - since = since, - ), - ), - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("t" to hashtagsToFollow), - kinds = - listOf( - InteractiveStorySceneEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - ), - limit = 100, - since = since, - ), - ), - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("I" to listOf(HashtagId.toScope(hashtag))), - kinds = listOf(CommentEvent.KIND), - limit = 200, - since = since, - ), - ), - ) + return relays.flatMap { relay -> + val since = since?.get(relay)?.time + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = hashtagsToFollowMap, + kinds = PostsByHashtagsKinds, + limit = 400, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = hashtagsToFollowMap, + kinds = PostsByHashtagKinds2, + limit = 100, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = hashtagScoreMap, + kinds = CommentKinds, + limit = 200, + since = since, + ), + ), + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFeedFilterSubAssembler.kt index 5ceaa226a..81668475c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFeedFilterSubAssembler.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class HashtagFeedFilterSubAssembler( client: NostrClient, @@ -31,8 +31,8 @@ class HashtagFeedFilterSubAssembler( ) : PerUniqueIdEoseManager(client, allKeys) { override fun updateFilter( key: HashtagQueryState, - since: Map?, - ): List = filterPostsByHashtags(key.hashtag, since) + since: SincePerRelayMap?, + ): List = filterPostsByHashtags(key.hashtag, key.relays, since) /** * Only one key per hashtag. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt index 87319df30..f21cce322 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt @@ -21,11 +21,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl // This allows multiple screen to be listening to tags, even the same tag class HashtagQueryState( val hashtag: String, + val relays: Set, ) { val lowercaseHashtag = hashtag.lowercase() } 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 index 665e1d69a..51c1c2048 100644 --- 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 @@ -20,21 +20,24 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource +import android.R.attr.tag import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable fun HashtagFilterAssemblerSubscription( - tag: String, - dataSource: HashtagFilterAssembler, + tag: Route.Hashtag, + accountViewModel: AccountViewModel, ) { // different screens get different states // even if they are tracking the same tag. val state = remember(tag) { - HashtagQueryState(tag) + HashtagQueryState(tag.hashtag, accountViewModel.account.followOutboxes.flow.value) } - KeyDataSourceSubscription(state, dataSource) + KeyDataSourceSubscription(state, accountViewModel.dataSources().hashtags) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index b3909a8b6..356bef90c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -50,7 +50,6 @@ 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.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically @@ -63,7 +62,7 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialogEasy +import com.vitorpamplona.amethyst.ui.actions.RelaySelectionDialog import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia @@ -241,10 +240,10 @@ private fun NewPostScreenInner( }, ) { pad -> if (postViewModel.showRelaysDialog) { - RelaySelectionDialogEasy( + RelaySelectionDialog( preSelectedList = postViewModel.relayList ?: persistentListOf(), onClose = { postViewModel.showRelaysDialog = false }, - onPost = { postViewModel.relayList = it.map { it.url }.toImmutableList() }, + onPost = { postViewModel.relayList = it }, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 00f4996d5..2ef0869e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -74,6 +74,7 @@ import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag 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.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash @@ -215,7 +216,7 @@ open class ShortNotePostViewModel : override val zapRaiserAmount = mutableStateOf(null) var showRelaysDialog by mutableStateOf(false) - var relayList by mutableStateOf?>(null) + var relayList by mutableStateOf?>(null) fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress() @@ -471,11 +472,11 @@ open class ShortNotePostViewModel : val template = createTemplate() ?: return val relayList = relayList - if (nip95attachments.isNotEmpty() && relayList != null) { + if (nip95attachments.isNotEmpty() && relayList != null && relayList.isNotEmpty()) { val usedImages = template.tags.taggedQuoteIds().toSet() nip95attachments.forEach { - if (usedImages.contains(it.second.id) == true) { - account?.sendNip95Privately(it.first, it.second, relayList) + if (usedImages.contains(it.second.id)) { + account?.sendNip95(it.first, it.second, relayList.toSet()) } } } @@ -715,17 +716,7 @@ open class ShortNotePostViewModel : fun reloadRelaySet() { val account = accountViewModel?.account ?: return - val nip65 = account.normalizedNIP65WriteRelayList.value - val private = account.normalizedPrivateOutBoxRelaySet.value - val local = account.settings.localRelayServers - - relayList = - if (nip65.isEmpty()) { - account.activeWriteRelays().map { it.url }.toImmutableList() - } else { - val combined: Set = (nip65 + private + local) - combined.toImmutableList() - } + relayList = account.outboxRelays.flow.value.toImmutableList() } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt index 4a1cedc05..81e4ecc14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt @@ -23,6 +23,7 @@ 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.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams @@ -31,8 +32,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent class HomeConversationsFeedFilter( @@ -40,9 +39,7 @@ class HomeConversationsFeedFilter( ) : AdditiveFeedFilter() { override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value - override fun showHiddenKey(): Boolean = - account.settings.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.settings.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + override fun showHiddenKey(): Boolean = account.liveHomeFollowLists.value is MutedAuthorsByOutboxTopNavFilter override fun feed(): List { val filterParams = buildFilterParams(account) @@ -58,10 +55,8 @@ class HomeConversationsFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultHomeFollowList.value, followLists = account.liveHomeFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) private fun innerApplyFilter(collection: Collection): Set { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt index 3806261a8..e4b15204e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt @@ -25,6 +25,10 @@ import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.EphemeralChatChannel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.ui.dal.AdditiveComplexFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent @@ -41,42 +45,32 @@ class HomeLiveFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultHomeFollowList.value, followLists = account.liveHomeFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) fun limitTime() = TimeUtils.fifteenMinutesAgo() override fun feed(): List { - val gRelays = account.activeGlobalRelays().toSet() val filterParams = buildFilterParams(account) val fiveMinsAgo = limitTime() val list = - LocalCache.channels.filter { id, channel -> - shouldIncludeChannel(channel, gRelays, filterParams, fiveMinsAgo) + LocalCache.ephemeralChannels.filter { id, channel -> + shouldIncludeChannel(channel, filterParams, fiveMinsAgo) } return sort(list.toSet()) } fun shouldIncludeChannel( - channel: Channel, - gRelays: Set, + channel: EphemeralChatChannel, filterParams: FilterByListParams, timeLimit: Long, ): Boolean = - if (channel is EphemeralChatChannel) { - val list = - channel.notes.filter { key, value -> - acceptableEvent(value, gRelays, filterParams, timeLimit) - } - list.isNotEmpty() - } else { - false - } + channel.notes.filter { key, value -> + acceptableEvent(value, filterParams, timeLimit) + }.isNotEmpty() override fun updateListWith( oldList: List, @@ -110,31 +104,37 @@ class HomeLiveFilter( } private fun applyFilter(collection: Collection): Set { - val gRelays = account.activeGlobalRelays().toSet() val filterParams = buildFilterParams(account) return collection.filterTo(HashSet()) { - acceptableEvent(it, gRelays, filterParams, limitTime()) + acceptableEvent(it, filterParams, limitTime()) } } private fun acceptableEvent( - it: Note, - globalRelays: Set, + note: Note, filterParams: FilterByListParams, timeLimit: Long, ): Boolean { - val createdAt = it.createdAt() ?: return false - val noteEvent = it.event - val isGlobalRelay = it.relays.any { globalRelays.contains(it.url) } + val createdAt = note.createdAt() ?: return false + val noteEvent = note.event return (noteEvent is EphemeralChatEvent) && createdAt > timeLimit && - filterParams.match(noteEvent, isGlobalRelay) + filterParams.match(noteEvent, note.relays) } fun sort(collection: Set): List { - val followingKeySet = - account.liveDiscoveryFollowLists.value?.authors ?: account.liveKind3Follows.value.authors + val topFilter = account.liveDiscoveryFollowLists.value + val topFilterAuthors = + when (topFilter) { + is AuthorsByOutboxTopNavFilter -> topFilter.authors + is MutedAuthorsByOutboxTopNavFilter -> topFilter.authors + is AllFollowsByOutboxTopNavFilter -> topFilter.authors + is SingleCommunityTopNavFilter -> topFilter.authors + else -> null + } + + val followingKeySet = topFilterAuthors ?: account.kind3FollowList.flow.value.authors val followCounts = collection.associate { it to followsThatParticipateOn(it, followingKeySet) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index ebd51792e..fd0d0e67e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal -import androidx.compose.ui.util.fastAny import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams @@ -36,8 +36,6 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip51Lists.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -47,31 +45,26 @@ class HomeNewThreadFeedFilter( ) : AdditiveFeedFilter() { override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value - override fun showHiddenKey(): Boolean = - account.settings.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.settings.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + override fun showHiddenKey(): Boolean = account.liveHomeFollowLists.value is MutedAuthorsByOutboxTopNavFilter fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultHomeFollowList.value, followLists = account.liveHomeFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) override fun feed(): List { - val gRelays = account.activeGlobalRelays().toSet() val filterParams = buildFilterParams(account) val notes = LocalCache.notes.filterIntoSet { _, note -> // Avoids processing addressables twice. - (note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, gRelays, filterParams) + (note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams) } val longFormNotes = LocalCache.addressables.filterIntoSet { _, note -> - acceptableEvent(note, gRelays, filterParams) + acceptableEvent(note, filterParams) } return sort(notes + longFormNotes) @@ -80,21 +73,18 @@ class HomeNewThreadFeedFilter( override fun applyFilter(collection: Set): Set = innerApplyFilter(collection) private fun innerApplyFilter(collection: Collection): Set { - val gRelays = account.activeGlobalRelays().toSet() val filterParams = buildFilterParams(account) return collection.filterTo(HashSet()) { - acceptableEvent(it, gRelays, filterParams) + acceptableEvent(it, filterParams) } } private fun acceptableEvent( it: Note, - globalRelays: Set, filterParams: FilterByListParams, ): Boolean { val noteEvent = it.event - val isGlobalRelay = it.relays.fastAny { globalRelays.contains(it.url) } return ( noteEvent is TextNoteEvent || noteEvent is ClassifiedsEvent || @@ -109,7 +99,7 @@ class HomeNewThreadFeedFilter( noteEvent is AudioTrackEvent || noteEvent is AudioHeaderEvent ) && - filterParams.match(noteEvent, isGlobalRelay) && + filterParams.match(noteEvent, it.relays) && it.isNewThread() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt index aab0c0375..06638abdc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt @@ -22,10 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.mixGeohashHashtagsCommunities.MixGeohashHashtagsCommunityEoseManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomeOutboxEventsEoseManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomeOutboxUsersEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import kotlinx.coroutines.CoroutineScope // This allows multiple screen to be listening to tags, even the same tag @@ -40,12 +38,12 @@ class HomeFilterAssembler( val group = listOf( HomeOutboxEventsEoseManager(client, ::allKeys), - HomeOutboxUsersEoseManager(client, ::allKeys), + // HomeOutboxUsersEoseManager(client, ::allKeys), // We can break it down, one for each sub if needed. // HashtagEventsFilterSubAssembler(client, ::allKeys), // GeohashEventsFilterSubAssembler(client, ::allKeys), // CommunityEventsFilterSubAssembler(client, ::allKeys), - MixGeohashHashtagsCommunityEoseManager(client, ::allKeys), + // MixGeohashHashtagsCommunityEoseManager(client, ::allKeys), ) override fun start() = group.forEach { it.start() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/mixGeohashHashtagsCommunities/MixGeohashHashtagsCommunityEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/mixGeohashHashtagsCommunities/MixGeohashHashtagsCommunityEoseManager.kt deleted file mode 100644 index eb7f79e67..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/mixGeohashHashtagsCommunities/MixGeohashHashtagsCommunityEoseManager.kt +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Copyright (c) 2025 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.mixGeohashHashtagsCommunities - -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeQueryState -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Geohash.filterHomePostsByGeohashes -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Hashtags.filterHomePostsByHashtags -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsFromCommunities -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.EOSETime -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class MixGeohashHashtagsCommunityEoseManager( - client: NostrClient, - allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun updateFilter( - key: HomeQueryState, - since: Map?, - ): List? = - listOfNotNull( - filterHomePostsByHashtags(key.followLists()?.hashtags, since), - filterHomePostsByGeohashes(key.followLists()?.geotags, since), - filterHomePostsByScopes(key.followLists()?.hashtagScopes, since), - filterHomePostsByScopes(key.followLists()?.geotagScopes, since), - filterHomePostsFromCommunities(key.followLists()?.addresses, since), - ).flatten() - - override fun user(query: HomeQueryState) = query.account.userProfile() - - fun HomeQueryState.followListsFlow() = account.liveHomeFollowLists - - fun HomeQueryState.followLists() = followListsFlow().value - - val userJobMap = mutableMapOf() - - override fun newSub(key: HomeQueryState): Subscription { - val user = user(key) - userJobMap[user]?.cancel() - userJobMap[user] = - key.scope.launch(Dispatchers.Default) { - key.followListsFlow().collectLatest { - invalidateFilters() - } - } - - return super.newSub(key) - } - - override fun endSub( - key: User, - subId: String, - ) { - return super.endSub(key, subId) - userJobMap[key]?.cancel() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt new file mode 100644 index 000000000..629750561 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2025 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.nip01Core + +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten +import kotlin.collections.mapNotNull +import kotlin.collections.sorted + +val HomePostsByGeohashKinds = + listOf( + TextNoteEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + LongTextNoteEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + ) + +fun filterHomePostsByGeohashes( + relay: NormalizedRelayUrl, + geotags: Set, + since: Long, +): List { + if (geotags.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = HomePostsByGeohashKinds, + tags = mapOf("g" to geotags.sorted()), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterHomePostsByGeohashes( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return geoSet.set.mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterHomePostsByGeohashes( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + + filterHomePostsByScopes( + relay = it.key, + scopesToLoad = it.value.geotagScopes, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt similarity index 51% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByFollows.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt index 9ae9a876c..98d6cc60e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt @@ -18,18 +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.screen.loggedIn.home.datasource.nip65Follows +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -40,52 +39,61 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEven import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.utils.TimeUtils -fun filterHomePostsByFollows( - follows: Map>?, - since: Map?, -): List { - if (follows != null && follows.isEmpty()) return emptyList() - - return listOf( - TypedFilter( - types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ClassifiedsEvent.KIND, - LongTextNoteEvent.KIND, - EphemeralChatEvent.KIND, - HighlightEvent.KIND, - ), - authors = follows, - limit = 400, - since = since, - ), - ), - TypedFilter( - types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - kinds = - listOf( - PollNoteEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - PinListEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - LiveActivitiesEvent.KIND, - WikiNoteEvent.KIND, - ), - authors = follows, - limit = 400, - since = since, - ), - ), +val HomePostsByGlobalKinds = + listOf( + TextNoteEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ClassifiedsEvent.KIND, + LongTextNoteEvent.KIND, + EphemeralChatEvent.KIND, + HighlightEvent.KIND, ) + +val HomePostsByGlobalKinds2 = + listOf( + PollNoteEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + PinListEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + LiveActivitiesEvent.KIND, + WikiNoteEvent.KIND, + ) + +fun filterHomePostsByGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (relays.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return relays.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relayUrl = it.key + listOf( + RelayBasedFilter( + relay = relayUrl, + filter = + Filter( + kinds = HomePostsByGlobalKinds, + limit = 400, + since = since, + ), + ), + RelayBasedFilter( + relay = relayUrl, + filter = + Filter( + kinds = HomePostsByGlobalKinds2, + limit = 400, + since = since, + ), + ), + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt new file mode 100644 index 000000000..f7bbd8a74 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt @@ -0,0 +1,107 @@ +/** + * Copyright (c) 2025 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.nip01Core + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten +import kotlin.collections.mapNotNull + +val HomePostsBuHashtagsKinds = + listOf( + TextNoteEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + LongTextNoteEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + AudioHeaderEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + CommentEvent.KIND, + WikiNoteEvent.KIND, + ) + +fun filterHomePostsByHashtags( + relay: NormalizedRelayUrl, + hashToLoad: Set, + since: Long?, +): List { + if (hashToLoad.isEmpty()) return emptyList() + + val hashtags = hashToLoad.flatMap { hashtagAlts(it) }.distinct().sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = HomePostsBuHashtagsKinds, + tags = mapOf("t" to hashtags), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterHomePostsByHashtags( + hashtagSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (hashtagSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return hashtagSet.set.mapNotNull { + if (it.value.hashtags.isEmpty()) { + null + } else { + val since = since?.get(it.key)?.time ?: defaultSince + + return filterHomePostsByHashtags( + relay = it.key, + hashToLoad = it.value.hashtags, + since = since, + ) + + filterHomePostsByScopes( + relay = it.key, + scopesToLoad = it.value.hashtagScopes, + since = since, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Geohash/FilterPostsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Geohash/FilterPostsByGeohashes.kt deleted file mode 100644 index 7f575c047..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Geohash/FilterPostsByGeohashes.kt +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Copyright (c) 2025 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.nip01Geohash - -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent - -fun filterHomePostsByGeohashes( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null - - return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - LongTextNoteEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - WikiNoteEvent.KIND, - CommentEvent.KIND, - ), - tags = mapOf("g" to hashToLoad.toList()), - limit = 100, - since = since, - ), - ), - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Geohash/GeohashEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Geohash/GeohashEventsEoseManager.kt deleted file mode 100644 index 2bbff0f7b..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Geohash/GeohashEventsEoseManager.kt +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Copyright (c) 2025 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.nip01Geohash - -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeQueryState -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes -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.EOSETime -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import kotlin.collections.flatten - -class GeohashEventsEoseManager( - client: NostrClient, - allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun updateFilter( - key: HomeQueryState, - since: Map?, - ): List? = - listOfNotNull( - filterHomePostsByGeohashes(key.followLists()?.geotags, since), - filterHomePostsByScopes(key.followLists()?.geotagScopes, since), - ).flatten() - - override fun user(query: HomeQueryState) = query.account.userProfile() - - fun HomeQueryState.followListsFlow() = account.liveHomeFollowLists - - fun HomeQueryState.followLists() = followListsFlow().value - - val userJobMap = mutableMapOf() - - override fun newSub(key: HomeQueryState): Subscription { - val user = user(key) - userJobMap[user]?.cancel() - userJobMap[user] = - key.scope.launch(Dispatchers.Default) { - key.followListsFlow().collectLatest { - invalidateFilters() - } - } - - return super.newSub(key) - } - - override fun endSub( - key: User, - subId: String, - ) { - return super.endSub(key, subId) - userJobMap[key]?.cancel() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Hashtags/FilterHomePostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Hashtags/FilterHomePostsByHashtags.kt deleted file mode 100644 index ed83014e6..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Hashtags/FilterHomePostsByHashtags.kt +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Copyright (c) 2025 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.nip01Hashtags - -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent - -fun filterHomePostsByHashtags( - hashToLoad: Set?, - since: Map?, -): List? { - if (hashToLoad == null || hashToLoad.isEmpty()) return null - - val hashtags = hashToLoad.map { hashtagAlts(it) }.flatten().distinct() - - return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - LongTextNoteEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - AudioHeaderEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - CommentEvent.KIND, - WikiNoteEvent.KIND, - ), - tags = mapOf("t" to hashtags), - limit = 100, - since = since, - ), - ), - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Hashtags/HashtagEventsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Hashtags/HashtagEventsFilterSubAssembler.kt deleted file mode 100644 index be9be75ba..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Hashtags/HashtagEventsFilterSubAssembler.kt +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Copyright (c) 2025 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.nip01Hashtags - -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeQueryState -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes -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.EOSETime -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class HashtagEventsFilterSubAssembler( - client: NostrClient, - allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun updateFilter( - key: HomeQueryState, - since: Map?, - ): List? = - listOfNotNull( - filterHomePostsByHashtags( - key.followLists()?.hashtags, - since, - ), - filterHomePostsByScopes(key.followLists()?.hashtagScopes, since), - ).flatten() - - override fun user(query: HomeQueryState) = query.account.userProfile() - - fun HomeQueryState.followListsFlow() = account.liveHomeFollowLists - - fun HomeQueryState.followLists() = followListsFlow().value - - val userJobMap = mutableMapOf() - - override fun newSub(key: HomeQueryState): Subscription { - val user = user(key) - userJobMap[user]?.cancel() - userJobMap[user] = - key.scope.launch(Dispatchers.Default) { - key.followListsFlow().collectLatest { - invalidateFilters() - } - } - - return super.newSub(key) - } - - override fun endSub( - key: User, - subId: String, - ) { - return super.endSub(key, subId) - userJobMap[key]?.cancel() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByGeohashScopes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByScopes.kt similarity index 73% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByGeohashScopes.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByScopes.kt index 6a9c7b582..04c29fb0f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByGeohashScopes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByScopes.kt @@ -20,24 +20,26 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip22Comments.CommentEvent +val CommentKinds = listOf(CommentEvent.KIND) + fun filterHomePostsByScopes( - scopesToLoad: Set?, - since: Map?, -): List? { - if (scopesToLoad == null || scopesToLoad.isEmpty()) return null + relay: NormalizedRelayUrl, + scopesToLoad: Set, + since: Long, +): List { + if (scopesToLoad.isEmpty()) return emptyList() return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = listOf(CommentEvent.KIND), + Filter( + kinds = CommentKinds, tags = mapOf("I" to scopesToLoad.toList()), limit = 100, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAllFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAllFollows.kt new file mode 100644 index 000000000..dd1f0a021 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAllFollows.kt @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2025 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.nip65Follows + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByGeohashes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsFromAllCommunities +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten + +fun filterHomePostsByAllFollows( + followsSet: AllFollowsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterHomePostsByAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterHomePostsByGeohashes(relay, it, since) + }, + it.value.geotagScopes?.let { + filterHomePostsByScopes(relay, it, since) + }, + it.value.hashtags?.let { + filterHomePostsByHashtags(relay, it, since) + }, + it.value.hashtagScopes?.let { + filterHomePostsByScopes(relay, it, since) + }, + it.value.communities?.let { + filterHomePostsFromAllCommunities(relay, it, since) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt new file mode 100644 index 000000000..386cf8afb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt @@ -0,0 +1,142 @@ +/** + * Copyright (c) 2025 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.nip65Follows + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip51Lists.PinListEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.mapNotNull + +val HomePostsKinds = + listOf( + TextNoteEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ClassifiedsEvent.KIND, + LongTextNoteEvent.KIND, + EphemeralChatEvent.KIND, + HighlightEvent.KIND, + ) + +val HomePostsKinds2 = + listOf( + PollNoteEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + PinListEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + LiveActivitiesEvent.KIND, + WikiNoteEvent.KIND, + ) + +fun filterHomePostsByAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = HomePostsKinds, + authors = authorList, + limit = 400, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = HomePostsKinds2, + authors = authorList, + limit = 400, + since = since, + ), + ), + ) +} + +fun filterHomePostsByAuthors( + authorSet: AuthorsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return authorSet.set.mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterHomePostsByAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterHomePostsByAuthors( + authorSet: MutedAuthorsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return authorSet.set.mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterHomePostsByAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt index 08515e7dd..bbe992d11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt @@ -21,12 +21,25 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByGeohashes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByGlobal +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByCommunity import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -40,8 +53,21 @@ class HomeOutboxEventsEoseManager( ) : PerUserAndFollowListEoseManager(client, allKeys) { override fun updateFilter( key: HomeQueryState, - since: Map?, - ): List? = filterHomePostsByFollows(key.followRelay(), since) + since: SincePerRelayMap?, + ): List? { + val feedSettings = key.followsPerRelay() + return when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterHomePostsByAllCommunities(feedSettings, since) + is AllFollowsByOutboxTopNavPerRelayFilterSet -> filterHomePostsByAllFollows(feedSettings, since) + is AuthorsByOutboxTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since) + is GlobalTopNavPerRelayFilterSet -> filterHomePostsByGlobal(feedSettings, since) + is HashtagTopNavPerRelayFilterSet -> filterHomePostsByHashtags(feedSettings, since) + is LocationTopNavPerRelayFilterSet -> filterHomePostsByGeohashes(feedSettings, since) + is MutedAuthorsByOutboxTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since) + is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since) + else -> emptyList() + } + } override fun user(key: HomeQueryState) = key.account.userProfile() @@ -51,9 +77,9 @@ class HomeOutboxEventsEoseManager( fun HomeQueryState.listName() = listNameFlow().value - fun HomeQueryState.followRelayFlow() = account.liveHomeListAuthorsPerRelay + fun HomeQueryState.followRelayFlow() = account.liveHomeFollowListsPerRelay - fun HomeQueryState.followRelay() = followRelayFlow().value + fun HomeQueryState.followsPerRelay() = followRelayFlow().value val userJobMap = mutableMapOf>() @@ -69,7 +95,7 @@ class HomeOutboxEventsEoseManager( } }, key.scope.launch(Dispatchers.Default) { - key.followRelayFlow().sample(5000).collectLatest { + key.followRelayFlow().sample(2000).collectLatest { invalidateFilters() } }, @@ -82,7 +108,7 @@ class HomeOutboxEventsEoseManager( key: User, subId: String, ) { - return super.endSub(key, subId) + super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/CommunityEventsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/CommunityEventsFilterSubAssembler.kt deleted file mode 100644 index 5d92c916c..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/CommunityEventsFilterSubAssembler.kt +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Copyright (c) 2025 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.nip72Communities - -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeQueryState -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.EOSETime -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class CommunityEventsFilterSubAssembler( - client: NostrClient, - allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun updateFilter( - key: HomeQueryState, - since: Map?, - ): List? = - filterHomePostsFromCommunities( - key.followLists()?.addresses, - since, - ) - - override fun user(query: HomeQueryState) = query.account.userProfile() - - fun HomeQueryState.followListsFlow() = account.liveHomeFollowLists - - fun HomeQueryState.followLists() = followListsFlow().value - - val userJobMap = mutableMapOf() - - override fun newSub(key: HomeQueryState): Subscription { - val user = user(key) - userJobMap[user]?.cancel() - userJobMap[user] = - key.scope.launch(Dispatchers.Default) { - key.followListsFlow().collectLatest { - invalidateFilters() - } - } - - return super.newSub(key) - } - - override fun endSub( - key: User, - subId: String, - ) { - return super.endSub(key, subId) - userJobMap[key]?.cancel() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsByAllCommunities.kt new file mode 100644 index 000000000..431091725 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsByAllCommunities.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2025 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.nip72Communities + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten + +fun filterHomePostsFromAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to HomePostsFromCommunityKindsStr, + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("a" to communityList), + kinds = HomePostsFromCommunityKinds, + limit = 300, + since = since, + ), + ), + ) +} + +fun filterHomePostsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return communitySet.set.mapNotNull { + filterHomePostsFromAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt index 288d20966..337a1cb12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -32,36 +33,87 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten +import kotlin.collections.mapNotNull -fun filterHomePostsFromCommunities( - communitiesToLoad: Set?, - since: Map?, -): List? { - if (communitiesToLoad == null || communitiesToLoad.isEmpty()) return null +val HomePostsFromCommunityKinds = + listOf( + TextNoteEvent.KIND, + LongTextNoteEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + WikiNoteEvent.KIND, + CommunityPostApprovalEvent.KIND, + CommentEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + ) +val HomePostsFromCommunityKindsStr = + listOf( + TextNoteEvent.KIND.toString(), + LongTextNoteEvent.KIND.toString(), + ClassifiedsEvent.KIND.toString(), + HighlightEvent.KIND.toString(), + WikiNoteEvent.KIND.toString(), + CommunityPostApprovalEvent.KIND.toString(), + CommentEvent.KIND.toString(), + InteractiveStoryPrologueEvent.KIND.toString(), + ) + +fun filterHomePostsFromCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), + // approved + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - LongTextNoteEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - WikiNoteEvent.KIND, - CommunityPostApprovalEvent.KIND, - CommentEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - ), + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( - "a" to communitiesToLoad.toList(), + "a" to listOf(community), + "k" to HomePostsFromCommunityKindsStr, ), - limit = 100, + limit = 200, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("a" to listOf(community)), + kinds = HomePostsFromCommunityKinds, + limit = 200, since = since, ), ), ) } + +fun filterHomePostsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return communitySet.set.mapNotNull { + filterHomePostsFromCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 18ecdfd18..76ec59bb3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.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 @@ -63,10 +64,8 @@ class NotificationFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultNotificationFollowList.value, followLists = account.liveNotificationFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) override fun feed(): List { @@ -108,7 +107,11 @@ class NotificationFeedFilter( noteEvent.pubKey } } else { - it.author?.pubkeyHex + if (it is AddressableNote) { + it.address.pubKeyHex + } else { + it.author?.pubkeyHex + } } return it.event !is ChannelCreateEvent && @@ -121,7 +124,7 @@ class NotificationFeedFilter( it.event !is NIP90ContentDiscoveryRequestEvent && it.event !is GiftWrapEvent && (it.event is LnZapEvent || notifAuthor != loggedInUserHex) && - (filterParams.isGlobal || filterParams.followLists?.authors?.contains(notifAuthor) == true) && + (filterParams.isGlobal(it.relays) || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor) == true) && it.event?.isTaggedUser(loggedInUserHex) ?: false && (filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) && tagsAnEventByUser(it, loggedInUserHex) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt index ae2ae2426..19ad86a31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt @@ -20,28 +20,27 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -fun filterUserProfileFollowers( - key: HexKey, - since: Map?, -): List { - if (key.isEmpty()) return emptyList() +val UserProfileFollowersKinds = listOf(ContactListEvent.KIND) - return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, +fun filterUserProfileFollowers( + user: User, + since: SincePerRelayMap?, +): List { + return user.inboxRelays().map { + RelayBasedFilter( + relay = it, filter = - SincePerRelayFilter( - kinds = listOf(ContactListEvent.KIND), - tags = mapOf("p" to listOf(key)), - since = since, + Filter( + kinds = UserProfileFollowersKinds, + tags = mapOf("p" to listOf(user.pubkeyHex)), + since = since?.get(it)?.time, ), - ), - ) + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt index 84ea68fcb..e1ebad1cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt @@ -20,38 +20,37 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent -fun filterUserProfileLists( - keys: Set, - since: Map?, -): List { - if (keys.isEmpty()) return emptyList() - - return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - BookmarkListEvent.KIND, - PeopleListEvent.KIND, - FollowListEvent.KIND, - AppRecommendationEvent.KIND, - ), - authors = keys.toList(), - limit = 100, - since = since, - ), - ), +val UserProfileListKinds = + listOf( + BookmarkListEvent.KIND, + PeopleListEvent.KIND, + FollowListEvent.KIND, + AppRecommendationEvent.KIND, ) + +fun filterUserProfileLists( + users: Map>, + since: SincePerRelayMap?, +): List { + return users.map { + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = UserProfileListKinds, + authors = it.value.sorted(), + limit = 100, + since = since?.get(it.key)?.time, + ), + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt index f0ac100d1..80dc2ec2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt @@ -20,38 +20,37 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -fun filterUserProfileMedia( - key: HexKey, - since: Map?, -): List { - if (key.isEmpty()) return emptyList() - - return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - PictureEvent.KIND, - ProfileGalleryEntryEvent.KIND, - VideoVerticalEvent.KIND, - VideoHorizontalEvent.KIND, - ), - authors = listOf(key), - limit = 200, - since = since, - ), - ), +val UserProfileMediaKinds = + listOf( + PictureEvent.KIND, + ProfileGalleryEntryEvent.KIND, + VideoVerticalEvent.KIND, + VideoHorizontalEvent.KIND, ) + +fun filterUserProfileMedia( + user: User, + since: SincePerRelayMap?, +): List { + return user.outboxRelays().map { relay -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = UserProfileMediaKinds, + authors = listOf(user.pubkeyHex), + limit = 200, + since = since?.get(relay)?.time, + ), + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMetadata.kt index 58564b764..696dba77b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMetadata.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMetadata.kt @@ -20,37 +20,38 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -fun filterUserProfileMetadata( - keys: Set, - since: Map?, -): List { - if (keys.isEmpty()) return emptyList() - - return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - MetadataEvent.KIND, - AdvertisedRelayListEvent.KIND, - ContactListEvent.KIND, - BadgeProfilesEvent.KIND, - ), - authors = keys.toList(), - since = since, - ), - ), +val UserProfileMetadataKinds = + listOf( + MetadataEvent.KIND, + AdvertisedRelayListEvent.KIND, + ContactListEvent.KIND, + BadgeProfilesEvent.KIND, ) + +fun filterUserProfileMetadata( + users: Map>, + since: SincePerRelayMap?, +): List { + if (users.isEmpty()) return emptyList() + + return users.map { + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = UserProfileMetadataKinds, + authors = it.value.sorted(), + since = since?.get(it.key)?.time, + ), + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt index 030e4bd56..43bd81fe9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt @@ -20,13 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -38,48 +37,52 @@ import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -fun filterUserProfilePosts( - key: HexKey, - since: Map?, -): List { - if (key.isEmpty()) return emptyList() - - return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - TextNoteEvent.KIND, - GenericRepostEvent.KIND, - RepostEvent.KIND, - LongTextNoteEvent.KIND, - PinListEvent.KIND, - PollNoteEvent.KIND, - HighlightEvent.KIND, - WikiNoteEvent.KIND, - ), - authors = listOf(key), - limit = 200, - since = since, - ), - ), - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - kinds = - listOf( - TorrentEvent.KIND, - TorrentCommentEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - CommentEvent.KIND, - ), - authors = listOf(key), - limit = 50, - since = since, - ), - ), +val UserProfilePostKinds1 = + listOf( + TextNoteEvent.KIND, + GenericRepostEvent.KIND, + RepostEvent.KIND, + LongTextNoteEvent.KIND, + PinListEvent.KIND, + PollNoteEvent.KIND, + HighlightEvent.KIND, + WikiNoteEvent.KIND, ) + +val UserProfilePostKinds2 = + listOf( + TorrentEvent.KIND, + TorrentCommentEvent.KIND, + InteractiveStoryPrologueEvent.KIND, + CommentEvent.KIND, + ) + +fun filterUserProfilePosts( + user: User, + since: SincePerRelayMap?, +): List { + return user.outboxRelays().map { relay -> + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = UserProfilePostKinds1, + authors = listOf(user.pubkeyHex), + limit = 200, + since = since?.get(relay)?.time, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = UserProfilePostKinds2, + authors = listOf(user.pubkeyHex), + limit = 50, + since = since?.get(relay)?.time, + ), + ), + ) + }.flatten() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt index d5c91849f..f9abf7c27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt @@ -20,29 +20,28 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -fun filterUserProfileZapsReceived( - key: HexKey, - since: Map?, -): List { - if (key.isEmpty()) return emptyList() +val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND) - return listOf( - TypedFilter( - types = COMMON_FEED_TYPES, +fun filterUserProfileZapsReceived( + user: User, + since: SincePerRelayMap?, +): List { + return user.inboxRelays().map { relay -> + RelayBasedFilter( + relay = relay, filter = - SincePerRelayFilter( - kinds = listOf(LnZapEvent.KIND), - tags = mapOf("p" to listOf(key)), + Filter( + kinds = UserProfileZapReceiverKinds, + tags = mapOf("p" to listOf(user.pubkeyHex)), limit = 200, - since = since, + since = since?.get(relay)?.time, ), - ), - ) + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt index 41737c983..be10dce70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFilterAssembler.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient // This allows multiple screen to be listening to tags, even the same tag class UserProfileQueryState( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFollowersFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFollowersFilterSubAssembler.kt index 609c7c427..dbfd21971 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFollowersFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileFollowersFilterSubAssembler.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class UserProfileFollowersFilterSubAssembler( client: NostrClient, @@ -31,11 +31,8 @@ class UserProfileFollowersFilterSubAssembler( ) : PerUserEoseManager(client, allKeys) { override fun updateFilter( key: UserProfileQueryState, - since: Map?, - ): List? = - listOfNotNull( - filterUserProfileFollowers(user(key).pubkeyHex, since), - ).flatten() + since: SincePerRelayMap?, + ): List? = filterUserProfileFollowers(user(key), since) override fun user(key: UserProfileQueryState) = key.user } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMediaFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMediaFilterSubAssembler.kt index 4c5086d01..40fadd1a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMediaFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMediaFilterSubAssembler.kt @@ -21,10 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import kotlin.collections.flatten +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class UserProfileMediaFilterSubAssembler( client: NostrClient, @@ -32,11 +31,8 @@ class UserProfileMediaFilterSubAssembler( ) : PerUserEoseManager(client, allKeys) { override fun updateFilter( key: UserProfileQueryState, - since: Map?, - ): List? = - listOfNotNull( - filterUserProfileMedia(user(key).pubkeyHex, since), - ).flatten() + since: SincePerRelayMap?, + ): List = filterUserProfileMedia(user(key), since) override fun user(key: UserProfileQueryState) = key.user } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt index 6d6d71cef..2ed12bc1d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt @@ -21,9 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.utils.mapOfSet import kotlin.collections.flatten class UserProfileMetadataFilterSubAssembler( @@ -32,15 +33,20 @@ class UserProfileMetadataFilterSubAssembler( ) : SingleSubEoseManager(client, allKeys) { override fun updateFilter( keys: List, - since: Map?, - ): List? { - val keys = keys.mapTo(mutableSetOf()) { key -> key.user.pubkeyHex } - - // TODO: Load outbox for each user. + since: SincePerRelayMap?, + ): List? { + val userPerRelay = + mapOfSet { + keys.mapTo(mutableSetOf()) { key -> key.user }.forEach { user -> + user.outboxRelays().forEach { relay -> + add(relay, user.pubkeyHex) + } + } + } return listOfNotNull( - filterUserProfileMetadata(keys, since), - filterUserProfileLists(keys, since), + filterUserProfileMetadata(userPerRelay, since), + filterUserProfileLists(userPerRelay, since), ).flatten() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfilePostsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfilePostsFilterSubAssembler.kt index 1d85fb9fa..a4223f5a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfilePostsFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfilePostsFilterSubAssembler.kt @@ -21,10 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import kotlin.collections.flatten +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class UserProfilePostsFilterSubAssembler( client: NostrClient, @@ -32,11 +31,8 @@ class UserProfilePostsFilterSubAssembler( ) : PerUserEoseManager(client, allKeys) { override fun updateFilter( key: UserProfileQueryState, - since: Map?, - ): List? = - listOfNotNull( - filterUserProfilePosts(user(key).pubkeyHex, since), - ).flatten() + since: SincePerRelayMap?, + ): List = filterUserProfilePosts(user(key), since) override fun user(key: UserProfileQueryState) = key.user } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileZapsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileZapsFilterSubAssembler.kt index 37b6763ba..6e2a89595 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileZapsFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileZapsFilterSubAssembler.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import kotlin.collections.flatten class UserProfileZapsFilterSubAssembler( @@ -32,10 +32,10 @@ class UserProfileZapsFilterSubAssembler( ) : PerUserEoseManager(client, allKeys) { override fun updateFilter( key: UserProfileQueryState, - since: Map?, - ): List? = + since: SincePerRelayMap?, + ): List? = listOfNotNull( - filterUserProfileZapsReceived(user(key).pubkeyHex, since), + filterUserProfileZapsReceived(user(key), since), ).flatten() override fun user(key: UserProfileQueryState) = key.user diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt index 61eaf4f3b..633842a57 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt @@ -78,10 +78,8 @@ class UserProfileGalleryFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultStoriesFollowList.value, followLists = account.liveStoriesFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) override fun sort(collection: Set): List = collection.sortedWith(DefaultFeedOrder) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt index d6bdc150d..fa9c00e4e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt @@ -40,11 +40,11 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness @Composable fun TabFollowedTags( baseUser: User, - account: AccountViewModel, + accountViewModel: AccountViewModel, nav: INav, ) { val items = - remember(baseUser) { + remember(baseUser.latestContactList?.id) { baseUser.latestContactList?.unverifiedFollowTagSet() } @@ -58,7 +58,7 @@ fun TabFollowedTags( itemsIndexed(items) { index, hashtag -> HashtagHeader( tag = hashtag, - account = account, + account = accountViewModel, onClick = { nav.nav(Route.Hashtag(hashtag)) }, ) HorizontalDivider( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt index 1d78d29dc..42281203d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt @@ -56,10 +56,10 @@ fun RelayFeedView( item, accountViewModel = accountViewModel, onAddRelay = { - nav.nav(Route.EditRelays(item.url)) + nav.nav(Route.EditRelays(item.url.url)) }, onRemoveRelay = { - nav.nav(Route.EditRelays(item.url)) + nav.nav(Route.EditRelays(item.url.url)) }, ) HorizontalDivider( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt index 000db6ae4..7c8333adc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt @@ -30,8 +30,8 @@ import com.vitorpamplona.amethyst.model.RelayInfo import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.ammolite.relays.BundledUpdate +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ReadWrite -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -49,7 +49,7 @@ class RelayFeedViewModel : val order = compareByDescending { it.lastEvent } .thenByDescending { it.counter } - .thenBy { it.url } + .thenBy { it.url.url } private val _feedContent = MutableStateFlow>(emptyList()) val feedContent = _feedContent.asStateFlow() @@ -79,14 +79,14 @@ class RelayFeedViewModel : } fun mergeRelays( - relaysBeingUsed: Map, - relays: Map?, + relaysBeingUsed: Map, + relays: Map?, ): List { val userRelaysBeingUsed = relaysBeingUsed.map { it.value } val currentUserRelays = relays?.mapNotNull { - val url = RelayUrlFormatter.normalize(it.key) + val url = it.key if (url !in relaysBeingUsed) { RelayInfo(url, 0, 0) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt index 708c7f10d..30a3e7ee5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt @@ -29,15 +29,19 @@ import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.uriToRoute import kotlinx.coroutines.CancellationException @Composable -fun NIP19QrCodeScanner(onScan: (Route?) -> Unit) { +fun NIP19QrCodeScanner( + accountViewModel: AccountViewModel, + onScan: (Route?) -> Unit, +) { SimpleQrCodeScanner { try { - onScan(uriToRoute(it)) + onScan(uriToRoute(it, accountViewModel.account)) } catch (e: Throwable) { if (e is CancellationException) throw e Log.e("NIP19 Scanner", "Error parsing $it", e) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt index 0392dc9ad..8c33d63e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRDialog.kt @@ -220,7 +220,7 @@ fun ShowQRDialog( } } } else { - NIP19QrCodeScanner { + NIP19QrCodeScanner(accountViewModel) { if (it == null) { presenting = true } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index 878351419..5368a2908 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -57,9 +57,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm.DMRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm.renderDMItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kind3.Kind3RelayListViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kind3.renderKind3Items -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kind3.renderKind3ProposalItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local.LocalRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local.renderLocalItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37.PrivateOutboxRelayListViewModel @@ -77,7 +74,6 @@ import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.grayText -import com.vitorpamplona.ammolite.relays.Constants @Composable fun AllRelayListScreen( @@ -95,10 +91,6 @@ fun MappedAllRelayListView( accountViewModel: AccountViewModel, newNav: INav, ) { - val kind3ViewModel: Kind3RelayListViewModel = viewModel() - val kind3FeedState by kind3ViewModel.relays.collectAsStateWithLifecycle() - val kind3Proposals by kind3ViewModel.proposedRelays.collectAsStateWithLifecycle() - val dmViewModel: DMRelayListViewModel = viewModel() val dmFeedState by dmViewModel.relays.collectAsStateWithLifecycle() @@ -116,7 +108,6 @@ fun MappedAllRelayListView( val localFeedState by localViewModel.relays.collectAsStateWithLifecycle() LaunchedEffect(Unit) { - kind3ViewModel.load(accountViewModel.account) dmViewModel.load(accountViewModel.account) nip65ViewModel.load(accountViewModel.account) searchViewModel.load(accountViewModel.account) @@ -146,7 +137,6 @@ fun MappedAllRelayListView( SaveButton( onPost = { - kind3ViewModel.create() dmViewModel.create() nip65ViewModel.create() searchViewModel.create() @@ -163,7 +153,6 @@ fun MappedAllRelayListView( Spacer(modifier = StdHorzSpacer) CloseButton( onPress = { - kind3ViewModel.clear() dmViewModel.clear() nip65ViewModel.clear() searchViewModel.clear() @@ -252,45 +241,10 @@ fun MappedAllRelayListView( ) } renderLocalItems(localFeedState, localViewModel, accountViewModel, newNav) - - item { - SettingsCategoryWithButton( - stringRes(R.string.kind_3_section), - stringRes(R.string.kind_3_section_description), - SettingsCategorySpacingModifier, - ) { - ResetKind3Relays(kind3ViewModel) - } - } - renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, newNav, relayToAdd) - - if (kind3Proposals.isNotEmpty()) { - item { - SettingsCategory( - stringRes(R.string.kind_3_recommended_section), - stringRes(R.string.kind_3_recommended_section_description), - SettingsCategorySpacingModifier, - ) - } - renderKind3ProposalItems(kind3Proposals, kind3ViewModel, accountViewModel, newNav) - } } } } -@Composable -fun ResetKind3Relays(postViewModel: Kind3RelayListViewModel) { - OutlinedButton( - onClick = { - postViewModel.deleteAll() - postViewModel.addAll(Constants.defaultRelays) - postViewModel.loadRelayDocuments() - }, - ) { - Text(stringRes(R.string.default_relays)) - } -} - @Composable fun ResetSearchRelays(postViewModel: SearchRelayListViewModel) { OutlinedButton( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationDialog.kt index 8e71bd5f7..84b450de9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationDialog.kt @@ -74,9 +74,10 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.ammolite.relays.RelayStats -import com.vitorpamplona.quartz.nip01Core.relay.RelayDebugMessage +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayDebugMessage +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import kotlinx.collections.immutable.toImmutableList @@ -85,7 +86,7 @@ import kotlinx.collections.immutable.toImmutableList @Composable fun RelayInformationDialog( onClose: () -> Unit, - relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo, + relay: NormalizedRelayUrl, relayInfo: Nip11RelayInformation, accountViewModel: AccountViewModel, nav: INav, @@ -93,9 +94,9 @@ fun RelayInformationDialog( val newNav = rememberExtendedNav(nav, onClose) val messages = - remember(relayBriefInfo) { + remember(relay) { RelayStats - .get(url = relayBriefInfo.url) + .get(url = relay) .messages .snapshot() .values @@ -119,7 +120,7 @@ fun RelayInformationDialog( TopAppBar( actions = {}, title = { - Text(relayBriefInfo.displayUrl) + Text(relay.displayUrl()) }, navigationIcon = { Row { @@ -148,11 +149,11 @@ fun RelayInformationDialog( ) { Column { RenderRelayIcon( - displayUrl = relayBriefInfo.displayUrl, - iconUrl = relayInfo.icon ?: relayBriefInfo.favIcon, + displayUrl = relay.displayUrl(), + iconUrl = relayInfo.icon, loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - RelayStats.get(url = relayBriefInfo.url).pingInMs, + RelayStats.get(relay).pingInMs, iconModifier = LargeRelayIconModifier, ) } @@ -162,7 +163,7 @@ fun RelayInformationDialog( Column(horizontalAlignment = Alignment.CenterHorizontally) { Title(relayInfo.name?.trim() ?: "") Spacer(modifier = HalfVertSpacer) - SubtitleContent(relayBriefInfo.url) + SubtitleContent(relay.url) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt index e9ec4f33f..1f13e3d63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt @@ -21,22 +21,18 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common import androidx.compose.runtime.Immutable -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.ammolite.relays.RelayStats -import com.vitorpamplona.quartz.nip01Core.relay.RelayStat -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Immutable data class BasicRelaySetupInfo( - val url: String, + val relay: NormalizedRelayUrl, val relayStat: RelayStat, val paidRelay: Boolean = false, -) { - val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url) -} +) -fun relaySetupInfoBuilder(url: String): BasicRelaySetupInfo { - val normalized = RelayUrlFormatter.normalize(url) +fun relaySetupInfoBuilder(normalized: NormalizedRelayUrl): BasicRelaySetupInfo { return BasicRelaySetupInfo( normalized, RelayStats.get(normalized), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt index 910d0dd10..6e54462ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChatMaxWidth +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @OptIn(ExperimentalFoundationApi::class) @Composable @@ -60,7 +61,7 @@ fun BasicRelaySetupInfoClickableRow( .combinedClickable( onClick = onClick, onLongClick = { - clipboardManager.setText(AnnotatedString(item.briefInfo.url)) + clipboardManager.setText(AnnotatedString(item.relay.url)) }, ), ) { @@ -68,11 +69,11 @@ fun BasicRelaySetupInfoClickableRow( verticalAlignment = Alignment.CenterVertically, modifier = HalfVertPadding, ) { - val iconUrlFromRelayInfoDoc by loadRelayInfo(item.url, accountViewModel) + val iconUrlFromRelayInfoDoc by loadRelayInfo(item.relay, accountViewModel) RenderRelayIcon( - item.briefInfo.displayUrl, - iconUrlFromRelayInfoDoc?.icon ?: item.briefInfo.favIcon, + item.relay.displayUrl(), + iconUrlFromRelayInfoDoc?.icon, loadProfilePicture, loadRobohash, item.relayStat.pingInMs, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt index 973913978..465dcf500 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt @@ -34,7 +34,6 @@ import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache @Composable fun BasicRelaySetupInfoDialog( @@ -50,7 +49,7 @@ fun BasicRelaySetupInfoDialog( RelayInformationDialog( onClose = { relayInfo = null }, relayInfo = it.relayInfo, - relayBriefInfo = it.relayBriefInfo, + relay = it.relay, accountViewModel = accountViewModel, nav = nav, ) @@ -64,18 +63,18 @@ fun BasicRelaySetupInfoDialog( accountViewModel = accountViewModel, onClick = { accountViewModel.retrieveRelayDocument( - item.url, + relay = item.relay, onInfo = { - relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it) + relayInfo = RelayInfoDialog(item.relay, it) }, - onError = { url, errorCode, exceptionMessage -> + onError = { relay, errorCode, exceptionMessage -> val msg = when (errorCode) { Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> stringRes( context, R.string.relay_information_document_error_assemble_url, - url, + relay.url, exceptionMessage, ) @@ -83,7 +82,7 @@ fun BasicRelaySetupInfoDialog( stringRes( context, R.string.relay_information_document_error_assemble_url, - url, + relay.url, exceptionMessage, ) @@ -91,7 +90,7 @@ fun BasicRelaySetupInfoDialog( stringRes( context, R.string.relay_information_document_error_assemble_url, - url, + relay.url, exceptionMessage, ) @@ -99,7 +98,7 @@ fun BasicRelaySetupInfoDialog( stringRes( context, R.string.relay_information_document_error_assemble_url, - url, + relay.url, exceptionMessage, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt index 067beb38d..d53a68e26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.replace +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -46,14 +47,14 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { loadRelayDocuments() } - abstract fun getRelayList(): List? + abstract fun getRelayList(): List? - abstract fun saveRelayList(urlList: List) + abstract fun saveRelayList(urlList: List) fun create() { if (hasModified) { viewModelScope.launch(Dispatchers.IO) { - saveRelayList(_relays.value.map { it.url }) + saveRelayList(_relays.value.map { it.relay }) clear() } } @@ -63,8 +64,10 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { _relays.value.forEach { item -> Nip11CachedRetriever.loadRelayInfo( - dirtyUrl = item.url, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForDirty(item.url)) }, + relay = item.relay, + okHttpClient = { + Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForClean(item.relay)) + }, onInfo = { togglePaidRelay(item, it.limitation?.payment_required ?: false) }, @@ -81,14 +84,14 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { relayList .map { relaySetupInfoBuilder(it) } - .distinctBy { it.url } + .distinctBy { it.relay } .sortedBy { it.relayStat.receivedBytes } .reversed() } } fun addRelay(relay: BasicRelaySetupInfo) { - if (relays.value.any { it.url == relay.url }) return + if (relays.value.any { it.relay == relay.relay }) return _relays.update { it.plus(relay) } hasModified = true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayNameAndRemoveButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayNameAndRemoveButton.kt index 6915ce6b4..183751a32 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayNameAndRemoveButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayNameAndRemoveButton.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.WarningColor import com.vitorpamplona.amethyst.ui.theme.allGoodColor +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @OptIn(ExperimentalFoundationApi::class) @Composable @@ -56,12 +57,12 @@ fun RelayNameAndRemoveButton( Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Text( - text = item.briefInfo.displayUrl, + text = item.relay.displayUrl(), modifier = Modifier.combinedClickable( onClick = onClick, onLongClick = { - clipboardManager.setText(AnnotatedString(item.briefInfo.url)) + clipboardManager.setText(AnnotatedString(item.relay.url)) }, ), maxLines = 1, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt index f21bc2e09..9eadc8c94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt @@ -47,6 +47,8 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @Preview @Composable @@ -57,7 +59,7 @@ fun RelayUrlEditFieldPreview() { } @Composable -fun RelayUrlEditField(onNewRelay: (String) -> Unit) { +fun RelayUrlEditField(onNewRelay: (NormalizedRelayUrl) -> Unit) { var url by remember { mutableStateOf("") } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Size10dp)) { @@ -85,8 +87,11 @@ fun RelayUrlEditField(onNewRelay: (String) -> Unit) { KeyboardActions( onGo = { if (url.isNotBlank() && url != "/") { - onNewRelay(url) - url = "" + val relay = RelayUrlNormalizer.normalizeOrNull(url) + if (relay != null) { + onNewRelay(relay) + url = "" + } } }, ), @@ -95,8 +100,11 @@ fun RelayUrlEditField(onNewRelay: (String) -> Unit) { Button( onClick = { if (url.isNotBlank() && url != "/") { - onNewRelay(url) - url = "" + val relay = RelayUrlNormalizer.normalizeOrNull(url) + if (relay != null) { + onNewRelay(relay) + url = "" + } } }, shape = ButtonBorder, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt index 6abd8e54b..50c4fbf22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt @@ -64,7 +64,7 @@ fun LazyListScope.renderDMItems( accountViewModel: AccountViewModel, nav: INav, ) { - itemsIndexed(feedState, key = { _, item -> "DM" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "DM" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt index dc4768918..581ed8764 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class DMRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = account.getDMRelayList()?.relays() + override fun getRelayList(): List? = account.dmRelayList.getDMRelayList()?.relays() - override fun saveRelayList(urlList: List) { + override fun saveRelayList(urlList: List) { account.saveDMRelayList(urlList) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListView.kt deleted file mode 100644 index 8ad32dacb..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListView.kt +++ /dev/null @@ -1,824 +0,0 @@ -/** - * Copyright (c) 2025 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.relays.kind3 - -import android.widget.Toast -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cancel -import androidx.compose.material.icons.filled.DeleteSweep -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Groups -import androidx.compose.material.icons.filled.Paid -import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.SyncProblem -import androidx.compose.material.icons.filled.Upload -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -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.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -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.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.service.Nip11CachedRetriever -import com.vitorpamplona.amethyst.service.Nip11Retriever -import com.vitorpamplona.amethyst.service.countToHumanReadable -import com.vitorpamplona.amethyst.service.countToHumanReadableBytes -import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.rememberExtendedNav -import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon -import com.vitorpamplona.amethyst.ui.painterRes -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.recommendations.Kind3RelayProposalSetupInfo -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.recommendations.Kind3RelaySetupInfoProposalDialog -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.ButtonBorder -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding -import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding -import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier -import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChatMaxWidth -import com.vitorpamplona.amethyst.ui.theme.Size30Modifier -import com.vitorpamplona.amethyst.ui.theme.Size35dp -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.amethyst.ui.theme.WarningColor -import com.vitorpamplona.amethyst.ui.theme.allGoodColor -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.amethyst.ui.theme.warningColor -import com.vitorpamplona.ammolite.relays.Constants.activeTypesGlobalChats -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache -import com.vitorpamplona.ammolite.relays.RelayStats -import com.vitorpamplona.quartz.nip01Core.relay.RelayStat -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter -import kotlinx.coroutines.launch - -@Composable -fun Kind3RelayListView( - feedState: List, - postViewModel: Kind3RelayListViewModel, - accountViewModel: AccountViewModel, - onClose: () -> Unit, - nav: INav, - relayToAdd: String, -) { - val newNav = rememberExtendedNav(nav, onClose) - - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - LazyColumn(contentPadding = FeedPadding) { - renderKind3Items(feedState, postViewModel, accountViewModel, newNav, relayToAdd) - } - } -} - -fun LazyListScope.renderKind3Items( - feedState: List, - postViewModel: Kind3RelayListViewModel, - accountViewModel: AccountViewModel, - nav: INav, - relayToAdd: String, -) { - itemsIndexed(feedState, key = { _, item -> "kind3" + item.url }) { index, item -> - LoadRelayInfo( - item, - onToggleDownload = { postViewModel.toggleDownload(it) }, - onToggleUpload = { postViewModel.toggleUpload(it) }, - onToggleFollows = { postViewModel.toggleFollows(it) }, - onTogglePrivateDMs = { postViewModel.toggleMessages(it) }, - onTogglePublicChats = { postViewModel.togglePublicChats(it) }, - onToggleGlobal = { postViewModel.toggleGlobal(it) }, - onToggleSearch = { postViewModel.toggleSearch(it) }, - onDelete = { postViewModel.deleteRelay(it) }, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - item { - Spacer(modifier = StdVertSpacer) - Kind3RelayEditBox(relayToAdd) { postViewModel.addRelay(it) } - } -} - -fun LazyListScope.renderKind3ProposalItems( - feedState: List, - postViewModel: Kind3RelayListViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - itemsIndexed(feedState, key = { _, item -> "kind3proposal" + item.url }) { index, item -> - Kind3RelaySetupInfoProposalDialog( - item = item, - onAdd = { - postViewModel.addRelay(item) - }, - accountViewModel = accountViewModel, - nav = nav, - ) - HorizontalDivider( - thickness = DividerThickness, - ) - } -} - -@Preview -@Composable -fun ServerConfigPreview() { - ClickableRelayItem( - loadProfilePicture = true, - loadRobohash = false, - item = - Kind3BasicRelaySetupInfo( - url = "nostr.mom", - read = true, - write = true, - relayStat = - RelayStat( - errorCounter = 23, - receivedBytes = 10000, - sentBytes = 10000000, - spamCounter = 10, - ), - feedTypes = activeTypesGlobalChats, - paidRelay = true, - ), - onDelete = {}, - onToggleDownload = {}, - onToggleUpload = {}, - onToggleFollows = {}, - onTogglePrivateDMs = {}, - onTogglePublicChats = {}, - onToggleGlobal = {}, - onToggleSearch = {}, - onClick = {}, - ) -} - -@Composable -fun LoadRelayInfo( - item: Kind3BasicRelaySetupInfo, - onToggleDownload: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleUpload: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleFollows: (Kind3BasicRelaySetupInfo) -> Unit, - onTogglePrivateDMs: (Kind3BasicRelaySetupInfo) -> Unit, - onTogglePublicChats: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleGlobal: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleSearch: (Kind3BasicRelaySetupInfo) -> Unit, - onDelete: (Kind3BasicRelaySetupInfo) -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) } - val context = LocalContext.current - - relayInfo?.let { - RelayInformationDialog( - onClose = { relayInfo = null }, - relayInfo = it.relayInfo, - relayBriefInfo = it.relayBriefInfo, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - ClickableRelayItem( - item = item, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, - loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - onToggleDownload = onToggleDownload, - onToggleUpload = onToggleUpload, - onToggleFollows = onToggleFollows, - onTogglePrivateDMs = onTogglePrivateDMs, - onTogglePublicChats = onTogglePublicChats, - onToggleGlobal = onToggleGlobal, - onToggleSearch = onToggleSearch, - onDelete = onDelete, - onClick = { - accountViewModel.retrieveRelayDocument( - item.url, - onInfo = { - relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it) - }, - onError = { url, errorCode, exceptionMessage -> - val msg = - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - } - - accountViewModel.toastManager.toast( - stringRes(context, R.string.unable_to_download_relay_document), - msg, - ) - }, - ) - }, - ) -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ClickableRelayItem( - item: Kind3BasicRelaySetupInfo, - loadProfilePicture: Boolean, - loadRobohash: Boolean, - onToggleDownload: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleUpload: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleFollows: (Kind3BasicRelaySetupInfo) -> Unit, - onTogglePrivateDMs: (Kind3BasicRelaySetupInfo) -> Unit, - onTogglePublicChats: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleGlobal: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleSearch: (Kind3BasicRelaySetupInfo) -> Unit, - onDelete: (Kind3BasicRelaySetupInfo) -> Unit, - onClick: () -> Unit, -) { - val clipboardManager = LocalClipboardManager.current - Column( - Modifier - .fillMaxWidth() - .combinedClickable( - onClick = onClick, - onLongClick = { - clipboardManager.setText(AnnotatedString(item.briefInfo.url)) - }, - ), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 5.dp), - ) { - val relayInfo = Nip11CachedRetriever.getFromCache(item.url) - - RenderRelayIcon( - item.briefInfo.displayUrl, - relayInfo?.icon ?: item.briefInfo.favIcon, - loadProfilePicture, - loadRobohash, - item.relayStat.pingInMs, - LargeRelayIconModifier, - ) - - Spacer(modifier = HalfHorzPadding) - - Column(Modifier.weight(1f)) { - FirstLine(item, onClick, onDelete, ReactionRowHeightChatMaxWidth) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = ReactionRowHeightChatMaxWidth, - ) { - ActiveToggles( - item = item, - onToggleFollows = onToggleFollows, - onTogglePrivateDMs = onTogglePrivateDMs, - onTogglePublicChats = onTogglePublicChats, - onToggleGlobal = onToggleGlobal, - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = ReactionRowHeightChatMaxWidth, - ) { - StatusRow( - item = item, - onToggleDownload = onToggleDownload, - onToggleUpload = onToggleUpload, - modifier = HalfStartPadding.weight(1f), - ) - } - } - } - - HorizontalDivider(thickness = DividerThickness) - } -} - -@Composable -@OptIn(ExperimentalFoundationApi::class) -private fun StatusRow( - item: Kind3BasicRelaySetupInfo, - onToggleDownload: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleUpload: (Kind3BasicRelaySetupInfo) -> Unit, - modifier: Modifier, -) { - val scope = rememberCoroutineScope() - val context = LocalContext.current - - Icon( - imageVector = Icons.Default.Download, - contentDescription = stringRes(R.string.read_from_relay), - modifier = - Modifier - .size(15.dp) - .combinedClickable( - onClick = { onToggleDownload(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.read_from_relay), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.read) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - - Text( - text = countToHumanReadableBytes(item.relayStat.receivedBytes), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText, - ) - - Icon( - imageVector = Icons.Default.Upload, - stringRes(R.string.write_to_relay), - modifier = - Modifier - .size(15.dp) - .combinedClickable( - onClick = { onToggleUpload(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.write_to_relay), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.write) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - - Text( - text = countToHumanReadableBytes(item.relayStat.sentBytes), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText, - ) - - Icon( - imageVector = Icons.Default.SyncProblem, - stringRes(R.string.errors), - modifier = - Modifier - .size(15.dp) - .combinedClickable( - onClick = {}, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.errors), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.relayStat.errorCounter > 0) { - MaterialTheme.colorScheme.warningColor - } else { - MaterialTheme.colorScheme.allGoodColor - }, - ) - - Text( - text = countToHumanReadable(item.relayStat.errorCounter, "errors"), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText, - ) - - Icon( - imageVector = Icons.Default.DeleteSweep, - stringRes(R.string.spam), - modifier = - Modifier - .size(15.dp) - .combinedClickable( - onClick = {}, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.spam), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.relayStat.spamCounter > 0) { - MaterialTheme.colorScheme.warningColor - } else { - MaterialTheme.colorScheme.allGoodColor - }, - ) - - Text( - text = countToHumanReadable(item.relayStat.spamCounter, "spam"), - maxLines = 1, - fontSize = 12.sp, - modifier = modifier, - color = MaterialTheme.colorScheme.placeholderText, - ) -} - -@Composable -@OptIn(ExperimentalFoundationApi::class) -private fun ActiveToggles( - item: Kind3BasicRelaySetupInfo, - onToggleFollows: (Kind3BasicRelaySetupInfo) -> Unit, - onTogglePrivateDMs: (Kind3BasicRelaySetupInfo) -> Unit, - onTogglePublicChats: (Kind3BasicRelaySetupInfo) -> Unit, - onToggleGlobal: (Kind3BasicRelaySetupInfo) -> Unit, -) { - val scope = rememberCoroutineScope() - val context = LocalContext.current - - Text( - text = stringRes(id = R.string.active_for), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(start = 2.dp, end = 5.dp), - fontSize = 14.sp, - ) - - IconButton( - modifier = Size30Modifier, - onClick = { onToggleFollows(item) }, - ) { - Icon( - painterRes(R.drawable.ic_home), - stringRes(R.string.home_feed), - modifier = - Modifier - .padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onToggleFollows(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.home_feed), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.feedTypes.contains(FeedType.FOLLOWS)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - } - IconButton( - modifier = Size30Modifier, - onClick = { onTogglePrivateDMs(item) }, - ) { - Icon( - painterRes(R.drawable.ic_dm), - stringRes(R.string.private_message_feed), - modifier = - Modifier - .padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onTogglePrivateDMs(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.private_message_feed), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - } - IconButton( - modifier = Size30Modifier, - onClick = { onTogglePublicChats(item) }, - ) { - Icon( - imageVector = Icons.Default.Groups, - contentDescription = stringRes(R.string.public_chat_feed), - modifier = - Modifier - .padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onTogglePublicChats(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.public_chat_feed), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - } - IconButton( - modifier = Size30Modifier, - onClick = { onToggleGlobal(item) }, - ) { - Icon( - imageVector = Icons.Default.Public, - stringRes(R.string.global_feed), - modifier = - Modifier - .padding(horizontal = 5.dp) - .size(15.dp) - .combinedClickable( - onClick = { onToggleGlobal(item) }, - onLongClick = { - scope.launch { - Toast - .makeText( - context, - stringRes(context, R.string.global_feed), - Toast.LENGTH_SHORT, - ).show() - } - }, - ), - tint = - if (item.feedTypes.contains(FeedType.GLOBAL)) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.32f, - ) - }, - ) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun FirstLine( - item: Kind3BasicRelaySetupInfo, - onClick: () -> Unit, - onDelete: (Kind3BasicRelaySetupInfo) -> Unit, - modifier: Modifier, -) { - val clipboardManager = LocalClipboardManager.current - Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { - Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { - Text( - text = item.briefInfo.displayUrl, - modifier = - Modifier.combinedClickable( - onClick = onClick, - onLongClick = { - clipboardManager.setText(AnnotatedString(item.briefInfo.url)) - }, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - if (item.paidRelay) { - Icon( - imageVector = Icons.Default.Paid, - null, - modifier = Modifier.padding(start = 5.dp, top = 1.dp).size(14.dp), - tint = MaterialTheme.colorScheme.allGoodColor, - ) - } - } - - IconButton( - modifier = Modifier.size(30.dp), - onClick = { onDelete(item) }, - ) { - Icon( - imageVector = Icons.Default.Cancel, - contentDescription = stringRes(id = R.string.remove), - modifier = Modifier.padding(start = 10.dp).size(15.dp), - tint = WarningColor, - ) - } - } -} - -@Composable -fun Kind3RelayEditBox( - relayToAdd: String, - onNewRelay: (Kind3BasicRelaySetupInfo) -> Unit, -) { - var url by remember { mutableStateOf(relayToAdd) } - var read by remember { mutableStateOf(true) } - var write by remember { mutableStateOf(true) } - - Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.add_a_relay)) }, - modifier = Modifier.weight(1f), - value = url, - onValueChange = { url = it }, - placeholder = { - Text( - text = "server.com", - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) - }, - singleLine = true, - ) - - IconButton(onClick = { read = !read }) { - Icon( - imageVector = Icons.Default.Download, - contentDescription = stringRes(id = R.string.read_from_relay), - modifier = Modifier.size(Size35dp).padding(horizontal = 5.dp), - tint = - if (read) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.placeholderText - }, - ) - } - - IconButton(onClick = { write = !write }) { - Icon( - imageVector = Icons.Default.Upload, - contentDescription = stringRes(id = R.string.write_to_relay), - modifier = Modifier.size(Size35dp).padding(horizontal = 5.dp), - tint = - if (write) { - MaterialTheme.colorScheme.allGoodColor - } else { - MaterialTheme.colorScheme.placeholderText - }, - ) - } - - Button( - onClick = { - if (url.isNotBlank() && url != "/") { - val normalized = RelayUrlFormatter.normalize(url) - onNewRelay( - Kind3BasicRelaySetupInfo( - url = normalized, - read = read, - write = write, - feedTypes = activeTypesGlobalChats, - relayStat = RelayStats.get(normalized), - ), - ) - url = "" - write = true - read = true - } - }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = - if (url.isNotBlank()) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.placeholderText - }, - ), - ) { - Text(text = stringRes(id = R.string.add), color = Color.White) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListViewModel.kt deleted file mode 100644 index 70bf368a9..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/kind3/Kind3RelayListViewModel.kt +++ /dev/null @@ -1,293 +0,0 @@ -/** - * Copyright (c) 2025 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.relays.kind3 - -import androidx.compose.runtime.Stable -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.Nip11CachedRetriever -import com.vitorpamplona.amethyst.service.replace -import com.vitorpamplona.amethyst.service.togglePresenceInSet -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.recommendations.Kind3RelayProposalSetupInfo -import com.vitorpamplona.ammolite.relays.Constants -import com.vitorpamplona.ammolite.relays.Constants.activeTypesGlobalChats -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.RelaySetupInfo -import com.vitorpamplona.ammolite.relays.RelayStats -import com.vitorpamplona.quartz.nip65RelayList.RelayListRecommendationProcessor -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter -import kotlinx.collections.immutable.toImmutableSet -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -@Stable -class Kind3RelayListViewModel : ViewModel() { - private lateinit var account: Account - - private val _relays = MutableStateFlow>(emptyList()) - val relays = _relays.asStateFlow() - - private val _proposedRelays = MutableStateFlow>(emptyList()) - val proposedRelays = _proposedRelays.asStateFlow() - - var hasModified = false - - fun load(account: Account) { - this.account = account - clear() - loadRelayDocuments() - } - - fun create() { - if (hasModified) { - viewModelScope.launch(Dispatchers.IO) { - account.saveKind3RelayList( - relays.value.map { - RelaySetupInfo( - it.url, - it.read, - it.write, - it.feedTypes, - ) - }, - ) - clear() - } - } - } - - fun loadRelayDocuments() { - viewModelScope.launch(Dispatchers.IO) { - _relays.value.forEach { item -> - Nip11CachedRetriever.loadRelayInfo( - dirtyUrl = item.url, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForDirty(item.url)) }, - onInfo = { - togglePaidRelay(item, it.limitation?.payment_required ?: false) - }, - onError = { url, errorCode, exceptionMessage -> }, - ) - } - } - } - - fun clear() { - hasModified = false - _relays.update { - var relayFile = account.userProfile().latestContactList?.relays() - - if (relayFile != null) { - relayFile - .map { - val localInfoFeedTypes = - account.settings.localRelays - .filter { localRelay -> localRelay.url == it.key } - .firstOrNull() - ?.feedTypes - ?: Constants.defaultRelays - .filter { defaultRelay -> defaultRelay.url == it.key } - .firstOrNull() - ?.feedTypes - ?: activeTypesGlobalChats.toImmutableSet() - - Kind3BasicRelaySetupInfo( - url = RelayUrlFormatter.normalize(it.key), - read = it.value.read, - write = it.value.write, - feedTypes = localInfoFeedTypes, - relayStat = RelayStats.get(it.key), - ) - }.distinctBy { it.url } - .sortedBy { it.relayStat.receivedBytes } - .reversed() - } else { - account.settings.localRelays - .map { - Kind3BasicRelaySetupInfo( - url = RelayUrlFormatter.normalize(it.url), - read = it.read, - write = it.write, - feedTypes = it.feedTypes, - relayStat = RelayStats.get(it.url), - ) - }.distinctBy { it.url } - .sortedBy { it.relayStat.receivedBytes } - .reversed() - } - } - - refreshProposals() - } - - private fun refreshProposals() { - _proposedRelays.update { - val proposed = - RelayListRecommendationProcessor - .reliableRelaySetFor( - account.liveKind3Follows.value.authors.mapNotNull { - account.getNIP65RelayList(it) - }, - relayUrlsToIgnore = - _relays.value.mapNotNullTo(HashSet()) { - if (it.read && FeedType.FOLLOWS in it.feedTypes) { - it.url - } else { - null - } - }, - hasOnionConnection = false, - ).sortedByDescending { it.users.size } - - proposed.mapNotNull { - if (it.requiredToNotMissEvents) { - Kind3RelayProposalSetupInfo( - url = RelayUrlFormatter.normalize(it.url), - read = true, - write = false, - feedTypes = setOf(FeedType.FOLLOWS), - relayStat = RelayStats.get(it.url), - users = it.users.sorted(), - ) - } else { - null - } - } - } - } - - fun addAll(defaultRelays: Array) { - hasModified = true - - _relays.update { - defaultRelays - .map { - Kind3BasicRelaySetupInfo( - url = RelayUrlFormatter.normalize(it.url), - read = it.read, - write = it.write, - feedTypes = it.feedTypes, - relayStat = RelayStats.get(it.url), - ) - }.distinctBy { it.url } - .sortedBy { it.relayStat.receivedBytes } - .reversed() - } - } - - fun addRelay(relay: Kind3BasicRelaySetupInfo) { - if (relays.value.any { it.url == relay.url }) return - - _relays.update { it.plus(relay) } - - refreshProposals() - - hasModified = true - } - - fun addRelay(relay: Kind3RelayProposalSetupInfo) { - if (relays.value.any { it.url == relay.url }) return - - _relays.update { - it.plus( - Kind3BasicRelaySetupInfo( - relay.url, - relay.read, - relay.write, - relay.feedTypes, - relay.relayStat, - relay.paidRelay, - ), - ) - } - - refreshProposals() - - hasModified = true - } - - fun deleteRelay(relay: Kind3BasicRelaySetupInfo) { - _relays.update { it.minus(relay) } - - refreshProposals() - - hasModified = true - } - - fun deleteAll() { - _relays.update { relays -> emptyList() } - - refreshProposals() - - hasModified = true - } - - fun toggleDownload(relay: Kind3BasicRelaySetupInfo) { - _relays.update { it.replace(relay, relay.copy(read = !relay.read)) } - hasModified = true - } - - fun toggleUpload(relay: Kind3BasicRelaySetupInfo) { - _relays.update { it.replace(relay, relay.copy(write = !relay.write)) } - hasModified = true - } - - fun toggleFollows(relay: Kind3BasicRelaySetupInfo) { - val newTypes = relay.feedTypes.togglePresenceInSet(FeedType.FOLLOWS) - _relays.update { it.replace(relay, relay.copy(feedTypes = newTypes)) } - hasModified = true - } - - fun toggleMessages(relay: Kind3BasicRelaySetupInfo) { - val newTypes = relay.feedTypes.togglePresenceInSet(FeedType.PRIVATE_DMS) - _relays.update { it.replace(relay, relay.copy(feedTypes = newTypes)) } - hasModified = true - } - - fun togglePublicChats(relay: Kind3BasicRelaySetupInfo) { - val newTypes = relay.feedTypes.togglePresenceInSet(FeedType.PUBLIC_CHATS) - _relays.update { it.replace(relay, relay.copy(feedTypes = newTypes)) } - hasModified = true - } - - fun toggleGlobal(relay: Kind3BasicRelaySetupInfo) { - val newTypes = relay.feedTypes.togglePresenceInSet(FeedType.GLOBAL) - _relays.update { it.replace(relay, relay.copy(feedTypes = newTypes)) } - hasModified = true - } - - fun toggleSearch(relay: Kind3BasicRelaySetupInfo) { - val newTypes = relay.feedTypes.togglePresenceInSet(FeedType.SEARCH) - _relays.update { it.replace(relay, relay.copy(feedTypes = newTypes)) } - hasModified = true - } - - fun togglePaidRelay( - relay: Kind3BasicRelaySetupInfo, - paid: Boolean, - ) { - _relays.update { it.replace(relay, relay.copy(paidRelay = paid)) } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListView.kt index d11d524a7..e50b13994 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListView.kt @@ -64,7 +64,7 @@ fun LazyListScope.renderLocalItems( accountViewModel: AccountViewModel, nav: INav, ) { - itemsIndexed(feedState, key = { _, item -> "Local" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "Local" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt index d674a2cb7..615668f81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class LocalRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List = account.settings.localRelayServers.toList() + override fun getRelayList(): List = account.localRelayList.flow.value.toList() - override fun saveRelayList(urlList: List) { - account.settings.updateLocalRelayServers(urlList.toSet()) + override fun saveRelayList(urlList: List) { + account.localRelayList.saveRelayList(urlList) {} } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt index a159087ae..ed4be6a3e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt @@ -64,7 +64,7 @@ fun LazyListScope.renderPrivateOutboxItems( accountViewModel: AccountViewModel, nav: INav, ) { - itemsIndexed(feedState, key = { _, item -> "Outbox" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "Outbox" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt index e83602218..c6067fcdb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37 import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class PrivateOutboxRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = account.getPrivateOutboxRelayList()?.relays() + override fun getRelayList(): List? = account.privateStorageRelayList.getPrivateOutboxRelayList()?.relays() - override fun saveRelayList(urlList: List) { + override fun saveRelayList(urlList: List) { account.savePrivateOutboxRelayList(urlList) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt index 5d3c49462..829b36153 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt @@ -67,7 +67,7 @@ fun LazyListScope.renderNip65HomeItems( accountViewModel: AccountViewModel, nav: INav, ) { - itemsIndexed(feedState, key = { _, item -> "Nip65Home" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "Nip65Home" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteHomeRelay(item) }, @@ -88,7 +88,7 @@ fun LazyListScope.renderNip65NotifItems( accountViewModel: AccountViewModel, nav: INav, ) { - itemsIndexed(feedState, key = { _, item -> "Nip65Notif" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "Nip65Notif" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteNotifRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt index 9fcde2682..fee19cdde 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt @@ -29,7 +29,8 @@ import com.vitorpamplona.amethyst.service.Nip11CachedRetriever import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -57,8 +58,8 @@ class Nip65RelayListViewModel : ViewModel() { fun create() { if (hasModified) { viewModelScope.launch(Dispatchers.IO) { - val writes = _homeRelays.value.map { it.url }.toSet() - val reads = _notificationRelays.value.map { it.url }.toSet() + val writes = _homeRelays.value.map { it.relay }.toSet() + val reads = _notificationRelays.value.map { it.relay }.toSet() val urls = writes.union(reads) @@ -66,13 +67,14 @@ class Nip65RelayListViewModel : ViewModel() { urls.map { val type = if (writes.contains(it) && reads.contains(it)) { - AdvertisedRelayListEvent.AdvertisedRelayType.BOTH + AdvertisedRelayType.BOTH } else if (writes.contains(it)) { - AdvertisedRelayListEvent.AdvertisedRelayType.WRITE + AdvertisedRelayType.WRITE } else { - AdvertisedRelayListEvent.AdvertisedRelayType.READ + AdvertisedRelayType.READ } - AdvertisedRelayListEvent.AdvertisedRelayInfo(it, type) + + AdvertisedRelayInfo(it, type) }, ) clear() @@ -84,8 +86,8 @@ class Nip65RelayListViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { _homeRelays.value.forEach { item -> Nip11CachedRetriever.loadRelayInfo( - dirtyUrl = item.url, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForDirty(item.url)) }, + relay = item.relay, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForClean(item.relay)) }, onInfo = { toggleHomePaidRelay(item, it.limitation?.payment_required ?: false) }, @@ -95,8 +97,8 @@ class Nip65RelayListViewModel : ViewModel() { _notificationRelays.value.forEach { item -> Nip11CachedRetriever.loadRelayInfo( - dirtyUrl = item.url, - okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForDirty(item.url)) }, + relay = item.relay, + okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.shouldUseTorForClean(item.relay)) }, onInfo = { toggleNotifPaidRelay(item, it.limitation?.payment_required ?: false) }, @@ -109,28 +111,28 @@ class Nip65RelayListViewModel : ViewModel() { fun clear() { hasModified = false _homeRelays.update { - val relayList = account.getNIP65RelayList()?.writeRelays() ?: emptyList() + val relayList = account.nip65RelayList.getNIP65RelayList()?.writeRelaysNorm() ?: emptyList() relayList .map { relaySetupInfoBuilder(it) } - .distinctBy { it.url } + .distinctBy { it.relay } .sortedBy { it.relayStat.receivedBytes } .reversed() } _notificationRelays.update { - val relayList = account.getNIP65RelayList()?.readRelays() ?: emptyList() + val relayList = account.nip65RelayList.getNIP65RelayList()?.readRelaysNorm() ?: emptyList() relayList .map { relaySetupInfoBuilder(it) } - .distinctBy { it.url } + .distinctBy { it.relay } .sortedBy { it.relayStat.receivedBytes } .reversed() } } fun addHomeRelay(relay: BasicRelaySetupInfo) { - if (_homeRelays.value.any { it.url == relay.url }) return + if (_homeRelays.value.any { it.relay == relay.relay }) return _homeRelays.update { it.plus(relay) } hasModified = true @@ -154,7 +156,7 @@ class Nip65RelayListViewModel : ViewModel() { } fun addNotifRelay(relay: BasicRelaySetupInfo) { - if (_notificationRelays.value.any { it.url == relay.url }) return + if (_notificationRelays.value.any { it.relay == relay.relay }) return _notificationRelays.update { it.plus(relay) } hasModified = true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalDialog.kt deleted file mode 100644 index 5e720bd5d..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalDialog.kt +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Copyright (c) 2025 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.relays.recommendations - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.service.Nip11Retriever -import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationDialog -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache - -@Composable -fun Kind3RelaySetupInfoProposalDialog( - item: Kind3RelayProposalSetupInfo, - onAdd: (Kind3RelayProposalSetupInfo) -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) } - val context = LocalContext.current - - relayInfo?.let { - RelayInformationDialog( - onClose = { relayInfo = null }, - relayInfo = it.relayInfo, - relayBriefInfo = it.relayBriefInfo, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - Kind3RelaySetupInfoProposalRow( - item = item, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, - loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - onAdd = { - onAdd(item) - }, - accountViewModel = accountViewModel, - onClick = { - accountViewModel.retrieveRelayDocument( - item.url, - onInfo = { - relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it) - }, - onError = { url, errorCode, exceptionMessage -> - val msg = - when (errorCode) { - Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - - Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> - stringRes( - context, - R.string.relay_information_document_error_assemble_url, - url, - exceptionMessage, - ) - } - - accountViewModel.toastManager.toast( - stringRes(context, R.string.unable_to_download_relay_document), - msg, - ) - }, - ) - }, - nav = nav, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalRow.kt deleted file mode 100644 index f04bdbeef..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/recommendations/Kind3RelaySetupInfoProposalRow.kt +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Copyright (c) 2025 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.relays.recommendations - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Paid -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.note.AddRelayButton -import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon -import com.vitorpamplona.amethyst.ui.note.UserPicture -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo -import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding -import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier -import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChatMaxWidth -import com.vitorpamplona.amethyst.ui.theme.Size25dp -import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.ui.theme.allGoodColor -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.quartz.nip01Core.relay.RelayStat - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun Kind3RelaySetupInfoProposalRow( - item: Kind3RelayProposalSetupInfo, - loadProfilePicture: Boolean, - loadRobohash: Boolean, - onAdd: () -> Unit, - onClick: () -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - Column( - Modifier - .fillMaxWidth() - .clickable(onClick = onClick), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 5.dp), - ) { - val relayInfo by loadRelayInfo(item.url, accountViewModel) - - RenderRelayIcon( - item.briefInfo.displayUrl, - relayInfo?.icon ?: item.briefInfo.favIcon, - loadProfilePicture, - loadRobohash, - item.relayStat.pingInMs, - LargeRelayIconModifier, - ) - - Spacer(modifier = HalfHorzPadding) - - Column(Modifier.weight(1f)) { - Row(ReactionRowHeightChatMaxWidth, verticalAlignment = Alignment.CenterVertically) { - Text( - text = item.briefInfo.displayUrl, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - if (item.paidRelay) { - Icon( - imageVector = Icons.Default.Paid, - null, - modifier = - Modifier - .padding(start = 5.dp, top = 1.dp) - .size(14.dp), - tint = MaterialTheme.colorScheme.allGoodColor, - ) - } - } - } - - UsedBy(item, accountViewModel, nav) - - Column( - Modifier - .padding(start = 10.dp), - ) { - AddRelayButton(onAdd) - } - } - - HorizontalDivider(thickness = DividerThickness) - } -} - -@Preview -@Composable -fun UsedByPreview() { - ThemeComparisonColumn { - UsedBy( - item = - Kind3RelayProposalSetupInfo( - "wss://nos.lol", - true, - true, - COMMON_FEED_TYPES, - relayStat = RelayStat(), - paidRelay = false, - users = listOf("User1", "User2", "User3", "User4"), - ), - accountViewModel = mockAccountViewModel(), - nav = EmptyNav, - ) - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun UsedBy( - item: Kind3RelayProposalSetupInfo, - accountViewModel: AccountViewModel, - nav: INav, -) { - FlowRow(verticalArrangement = Arrangement.Center) { - item.users.getOrNull(0)?.let { - UserPicture( - userHex = it, - size = Size25dp, - accountViewModel = accountViewModel, - nav = nav, - ) - } - item.users.getOrNull(1)?.let { - UserPicture( - userHex = it, - size = Size25dp, - accountViewModel = accountViewModel, - nav = nav, - ) - } - item.users.getOrNull(2)?.let { - UserPicture( - userHex = it, - size = Size25dp, - accountViewModel = accountViewModel, - nav = nav, - ) - } - if (item.users.size > 3) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.height(Size25dp)) { - Text( - text = stringRes(R.string.and_more, item.users.size - 3), - maxLines = 1, - ) - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt index bc2cc0c73..495daa68d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt @@ -65,7 +65,7 @@ fun LazyListScope.renderSearchItems( accountViewModel: AccountViewModel, nav: INav, ) { - itemsIndexed(feedState, key = { _, item -> "Search" + item.url }) { index, item -> + itemsIndexed(feedState, key = { _, item -> "Search" + item.relay }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt index d489072bb..06edc49bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class SearchRelayListViewModel : BasicRelaySetupInfoModel() { - override fun getRelayList(): List? = account.getSearchRelayList()?.relays() + override fun getRelayList(): List? = account.searchRelayList.getSearchRelayList()?.relays() - override fun saveRelayList(urlList: List) { + override fun saveRelayList(urlList: List) { account.saveSearchRelayList(urlList) } } 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 6ca95b192..045d583c4 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 @@ -68,7 +68,7 @@ class SearchBarViewModel( .onEach(::updateDataSource) .stateIn(viewModelScope, SharingStarted.Eagerly, searchValue) - val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue)) + val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue), account) val searchResultsUsers = combine(searchValueFlow.debounce(100), invalidations.debounce(100)) { term, version -> @@ -82,7 +82,7 @@ class SearchBarViewModel( combine(searchValueFlow.debounce(100), invalidations) { term, version -> logTime("SearchBarViewModel findNotesStartingWith") { LocalCache - .findNotesStartingWith(term, account) + .findNotesStartingWith(term, account.hiddenUsers) .sortedWith(DefaultFeedOrder) } }.flowOn(Dispatchers.Default) 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 eb0fdfa25..2981f85d9 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 @@ -335,8 +335,8 @@ fun WatchAccountAndBlockList( accountViewModel: AccountViewModel, invalidate: () -> Unit, ) { - val transientSpammers by accountViewModel.account.transientHiddenUsers.collectAsStateWithLifecycle() - val blockListState by accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() + val transientSpammers by accountViewModel.account.hiddenUsers.transientHiddenUsers.collectAsStateWithLifecycle() + val blockListState by accountViewModel.account.hiddenUsers.flow.collectAsStateWithLifecycle() LaunchedEffect(accountViewModel, transientSpammers, blockListState) { invalidate() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt index 76fb9f019..b1c001bb8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedFilter.kt @@ -35,7 +35,7 @@ class HiddenAccountsFeedFilter( override fun showHiddenKey(): Boolean = true override fun feed(): List = - account.flowHiddenUsers.value.hiddenUsers.reversed().mapNotNull { + account.hiddenUsers.flow.value.hiddenUsers.reversed().mapNotNull { try { LocalCache.getOrCreateUser(it) } catch (e: Exception) { 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 index 59ffe6cb8..ec9b50976 100644 --- 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 @@ -31,6 +31,6 @@ class HiddenWordsFeedFilter( override fun showHiddenKey(): Boolean = true override fun feed(): List = - account.flowHiddenUsers.value.hiddenWords + account.hiddenUsers.flow.value.hiddenWords .toList() } 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 index 8cf06a1d4..69a19a8bd 100644 --- 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 @@ -32,5 +32,5 @@ class SpammerAccountsFeedFilter( override fun showHiddenKey(): Boolean = true - override fun feed(): List = account.transientHiddenUsers.value.map { LocalCache.getOrCreateUser(it) } + override fun feed(): List = account.hiddenUsers.transientHiddenUsers.value.map { LocalCache.getOrCreateUser(it) } } 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 8e9c54cb3..3f721fbc7 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 @@ -479,7 +479,7 @@ private fun FullBleedNoteCompose( val geo = remember { noteEvent.geoHashOrScope() } if (geo != null) { - DisplayLocation(geo, nav) + DisplayLocation(geo, accountViewModel, nav) } val baseReward = remember { noteEvent.bountyBaseReward()?.let { Reward(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedFilter.kt index 5f94ccae1..f1ae4c13a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/dal/ThreadFeedFilter.kt @@ -39,7 +39,7 @@ class ThreadFeedFilter( override fun feed(): List { val cachedSignatures: MutableMap = mutableMapOf() - val followingKeySet = account.liveKind3Follows.value.authors + val followingKeySet = account.kind3FollowList.flow.value.authors val eventsToWatch = ThreadAssembler().findThreadFor(noteId) ?: return emptyList() // Filter out drafts made by other accounts on device 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 index 11c3948c8..3e9152ff8 100644 --- 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 @@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies.ThreadEventLoaderSubAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies.ThreadFilterSubAssembler -import com.vitorpamplona.ammolite.relays.NostrClient import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient // This allows multiple screen to be listening to tags, even the same tag class ThreadQueryState( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterEventsInThreadForRoot.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterEventsInThreadForRoot.kt index 0e896d20c..c0cabe9ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterEventsInThreadForRoot.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterEventsInThreadForRoot.kt @@ -22,65 +22,68 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.sub import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter fun filterEventsInThreadForRoot( root: Note, - since: Map?, -): List { + since: SincePerRelayMap?, +): List { val addressRoot = if (root is AddressableNote) root.idHex else null val eventRoot = if (root !is AddressableNote) root.idHex else root.event?.id - val address = - if (addressRoot != null) { - listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("a" to listOf(addressRoot)), - since = since, - ), - ), - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("A" to listOf(addressRoot)), - since = since, - ), - ), - ) - } else { - emptyList() - } + return root.relayUrlsForReactions().toSet().flatMap { + val since = since?.get(it)?.time - val event = - if (eventRoot != null) { - listOf( - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("e" to listOf(eventRoot)), - since = since, - ), - ), - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - SincePerRelayFilter( - tags = mapOf("E" to listOf(eventRoot)), - since = since, - ), - ), - ) - } else { - emptyList() - } + val addressList = + if (addressRoot != null) { + listOf( + RelayBasedFilter( + relay = it, + filter = + Filter( + tags = mapOf("a" to listOf(addressRoot)), + since = since, + ), + ), + RelayBasedFilter( + relay = it, + filter = + Filter( + tags = mapOf("A" to listOf(addressRoot)), + since = since, + ), + ), + ) + } else { + emptyList() + } - return address + event + val eventList = + if (eventRoot != null) { + listOf( + RelayBasedFilter( + relay = it, + filter = + Filter( + tags = mapOf("e" to listOf(eventRoot)), + since = since, + ), + ), + RelayBasedFilter( + relay = it, + filter = + Filter( + tags = mapOf("E" to listOf(eventRoot)), + since = since, + ), + ), + ) + } else { + emptyList() + } + + addressList + eventList + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterMissingEventsForThread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterMissingEventsForThread.kt index 975844cf6..2a7745143 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterMissingEventsForThread.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterMissingEventsForThread.kt @@ -24,11 +24,12 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.ThreadAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents -import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -fun filterMissingEventsForThread(threadInfo: ThreadAssembler.ThreadInfo): List { - val missingEvents = mutableSetOf() +fun filterMissingEventsForThread(threadInfo: ThreadAssembler.ThreadInfo): List { + val missingEvents = mutableSetOf() val missingAddresses = mutableSetOf
() if (threadInfo.root.event == null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadEventLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadEventLoaderSubAssembler.kt index 0426f813c..c6703e69d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadEventLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadEventLoaderSubAssembler.kt @@ -22,10 +22,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.sub import com.vitorpamplona.amethyst.model.ThreadAssembler import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter /** * Loads all missing events in each thread. @@ -42,8 +42,8 @@ class ThreadEventLoaderSubAssembler( ) : PerUniqueIdEoseManager(client, allKeys, invalidateAfterEose = true) { override fun updateFilter( key: ThreadQueryState, - since: Map?, - ): List? { + since: SincePerRelayMap?, + ): List? { val branches = ThreadAssembler().findThreadFor(key.eventId) ?: return null return filterMissingEventsForThread(branches) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadFilterSubAssembler.kt index be3551859..fa3efb51b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/ThreadFilterSubAssembler.kt @@ -22,10 +22,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.sub import com.vitorpamplona.amethyst.model.ThreadAssembler import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter /** * Loads all events in the Thread that cites the root post. @@ -39,8 +39,8 @@ class ThreadFilterSubAssembler( ) : PerUniqueIdEoseManager(client, allKeys) { override fun updateFilter( key: ThreadQueryState, - since: Map?, - ): List? { + since: SincePerRelayMap?, + ): List? { val root = ThreadAssembler().findRoot(key.eventId) ?: return null return filterEventsInThreadForRoot(root, since) 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 1245283f1..e8f5f14c0 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 @@ -164,7 +164,7 @@ fun WatchAccountForVideoScreen( accountViewModel: AccountViewModel, ) { val listState by accountViewModel.account.liveStoriesFollowLists.collectAsStateWithLifecycle() - val hiddenUsers = accountViewModel.account.flowHiddenUsers.collectAsStateWithLifecycle() + val hiddenUsers = accountViewModel.account.hiddenUsers.flow.collectAsStateWithLifecycle() LaunchedEffect(accountViewModel, listState, hiddenUsers) { videoFeedContentState.checkKeysInvalidateDataAndSendToTop() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt index ec72c5633..3e669f0dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt @@ -121,10 +121,8 @@ class VideoFeedFilter( fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( - userHex = account.userProfile().pubkeyHex, - selectedListName = account.settings.defaultStoriesFollowList.value, followLists = account.liveStoriesFollowLists.value, - hiddenUsers = account.flowHiddenUsers.value, + hiddenUsers = account.hiddenUsers.flow.value, ) override fun sort(collection: Set): List = collection.sortedWith(DefaultFeedOrder) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/FeedBasis.kt similarity index 50% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByGeohash.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/FeedBasis.kt index 04d88894d..6915b0198 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/FeedBasis.kt @@ -18,51 +18,21 @@ * 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.subassemblies +package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource -import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.SUPPORTED_VIDEO_FEED_MIME_TYPES -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent -fun filterPictureAndVideoByGeohash( - hashToLoad: Set?, - since: Map?, -): List { - if (hashToLoad == null || hashToLoad.isEmpty()) return emptyList() +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() - val geoHashes = hashToLoad.toList() +val PictureAndVideoKinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND) +val PictureAndVideoKTags = listOf(PictureEvent.KIND.toString(), VideoHorizontalEvent.KIND.toString(), VideoVerticalEvent.KIND.toString()) - return listOf( - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), - tags = mapOf("g" to geoHashes), - limit = 100, - since = since, - ), - ), - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - SincePerRelayFilter( - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), - tags = - mapOf( - "g" to geoHashes, - "m" to SUPPORTED_VIDEO_FEED_MIME_TYPES, - ), - limit = 100, - since = since, - ), - ), - ) -} +val PictureAndVideoLegacyKinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND) +val PictureAndVideoLegacyKTags = listOf(FileHeaderEvent.KIND.toString(), FileStorageHeaderEvent.KIND.toString()) +val LegacyMimeTypes = SUPPORTED_VIDEO_FEED_MIME_TYPES +val LegacyMimeTypeMap = mapOf("m" to LegacyMimeTypes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt index f1dbb2a70..d1f95d119 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt @@ -22,14 +22,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.VideoMixGeohashHashtagsFilterSubAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.VideoOutboxEventsFilterSubAssembler -import com.vitorpamplona.ammolite.relays.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import kotlinx.coroutines.CoroutineScope -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() - // This allows multiple screen to be listening to tags, even the same tag class VideoQueryState( val account: Account, @@ -42,7 +38,6 @@ class VideoFilterAssembler( val group = listOf( VideoOutboxEventsFilterSubAssembler(client, ::allKeys), - VideoMixGeohashHashtagsFilterSubAssembler(client, ::allKeys), ) override fun start() = group.forEach { it.start() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByFollows.kt deleted file mode 100644 index 392774e8b..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByFollows.kt +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Copyright (c) 2025 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.subassemblies - -import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.SUPPORTED_VIDEO_FEED_MIME_TYPES -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent - -fun filterPictureAndVideoByFollows( - follows: Map>?, - since: Map?, -): List { - if (follows != null && follows.isEmpty()) return emptyList() - - return listOf( - TypedFilter( - types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), - limit = 200, - since = since, - ), - ), - TypedFilter( - types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS), - filter = - SinceAuthorPerRelayFilter( - authors = follows, - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), - limit = 200, - tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES), - since = since, - ), - ), - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByHashtag.kt deleted file mode 100644 index a3be33c21..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/FilterPictureAndVideoByHashtag.kt +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Copyright (c) 2025 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.subassemblies - -import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.SUPPORTED_VIDEO_FEED_MIME_TYPES -import com.vitorpamplona.ammolite.relays.FeedType -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.EOSETime -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts -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 kotlin.collections.flatten - -fun filterPictureAndVideoByHashtag( - hashToLoad: Set?, - since: Map?, -): List { - if (hashToLoad == null || hashToLoad.isEmpty()) return emptyList() - - val hashtags = hashToLoad.map { hashtagAlts(it) }.flatten().distinct() - - if (hashtags.isEmpty()) return emptyList() - - return listOf( - TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - SincePerRelayFilter( - kinds = listOf(PictureEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), - tags = mapOf("t" to hashtags), - limit = 100, - since = since, - ), - ), - TypedFilter( - types = setOf(FeedType.FOLLOWS), - filter = - SincePerRelayFilter( - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), - tags = - mapOf( - "t" to hashtags, - "m" to SUPPORTED_VIDEO_FEED_MIME_TYPES, - ), - limit = 100, - since = since, - ), - ), - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoMixGeohashHashtagsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoMixGeohashHashtagsFilterSubAssembler.kt deleted file mode 100644 index bcabeff4b..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoMixGeohashHashtagsFilterSubAssembler.kt +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Copyright (c) 2025 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.subassemblies - -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoQueryState -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.EOSETime -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class VideoMixGeohashHashtagsFilterSubAssembler( - client: NostrClient, - allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun updateFilter( - key: VideoQueryState, - since: Map?, - ): List? = - filterPictureAndVideoByHashtag( - key.followLists()?.hashtags, - since, - ) + - filterPictureAndVideoByGeohash( - key.followLists()?.geotags, - since, - ) - - override fun user(key: VideoQueryState) = key.account.userProfile() - - fun VideoQueryState.followListsFlow() = account.liveStoriesFollowLists - - fun VideoQueryState.followLists() = followListsFlow().value - - val userJobMap = mutableMapOf() - - override fun newSub(key: VideoQueryState): Subscription { - userJobMap[key.account.userProfile()]?.cancel() - userJobMap[key.account.userProfile()] = - key.scope.launch(Dispatchers.Default) { - key.followListsFlow().collectLatest { - invalidateFilters() - } - } - - return super.newSub(key) - } - - override fun endSub( - key: User, - subId: String, - ) { - return super.endSub(key, subId) - userJobMap[key]?.cancel() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt index 0cc3cbd00..cc5787e9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt @@ -21,12 +21,27 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoQueryState -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core.filterPictureAndVideoByGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core.filterPictureAndVideoByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core.filterPictureAndVideoGlobal +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip65Follows.filterPictureAndVideoByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip65Follows.filterPictureAndVideoByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip72Communities.filterPictureAndVideoByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip72Communities.filterPictureAndVideoByCommunity import com.vitorpamplona.ammolite.relays.datasources.Subscription -import com.vitorpamplona.ammolite.relays.filters.EOSETime +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -40,8 +55,21 @@ class VideoOutboxEventsFilterSubAssembler( ) : PerUserAndFollowListEoseManager(client, allKeys) { override fun updateFilter( key: VideoQueryState, - since: Map?, - ): List? = filterPictureAndVideoByFollows(key.followsPerRelay(), since) + since: SincePerRelayMap?, + ): List? { + val feedSettings = key.followsPerRelay() + return when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterPictureAndVideoByAllCommunities(feedSettings, since) + is AllFollowsByOutboxTopNavPerRelayFilterSet -> filterPictureAndVideoByFollows(feedSettings, since) + is AuthorsByOutboxTopNavPerRelayFilterSet -> filterPictureAndVideoByAuthors(feedSettings, since) + is GlobalTopNavPerRelayFilterSet -> filterPictureAndVideoGlobal(feedSettings, since) + is HashtagTopNavPerRelayFilterSet -> filterPictureAndVideoByHashtag(feedSettings, since) + is LocationTopNavPerRelayFilterSet -> filterPictureAndVideoByGeohash(feedSettings, since) + is MutedAuthorsByOutboxTopNavPerRelayFilterSet -> filterPictureAndVideoByAuthors(feedSettings, since) + is SingleCommunityTopNavPerRelayFilterSet -> filterPictureAndVideoByCommunity(feedSettings, since) + else -> emptyList() + } + } override fun user(key: VideoQueryState) = key.account.userProfile() @@ -51,11 +79,7 @@ class VideoOutboxEventsFilterSubAssembler( fun VideoQueryState.listName() = listNameFlow().value - fun VideoQueryState.followListsFlow() = account.liveStoriesFollowLists - - fun VideoQueryState.followLists() = followListsFlow().value - - fun VideoQueryState.followsPerRelayFlow() = account.liveStoriesListAuthorsPerRelay + fun VideoQueryState.followsPerRelayFlow() = account.liveStoriesFollowListsPerRelay fun VideoQueryState.followsPerRelay() = followsPerRelayFlow().value @@ -67,11 +91,6 @@ class VideoOutboxEventsFilterSubAssembler( userJobMap[user]?.forEach { it.cancel() } userJobMap[user] = listOf( - key.scope.launch(Dispatchers.Default) { - key.listNameFlow().collectLatest { - invalidateFilters() - } - }, key.scope.launch(Dispatchers.Default) { key.followsPerRelayFlow().sample(5000).collectLatest { invalidateFilters() @@ -86,7 +105,7 @@ class VideoOutboxEventsFilterSubAssembler( key: User, subId: String, ) { - return super.endSub(key, subId) + super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt new file mode 100644 index 000000000..277c21e6b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2025 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.subassemblies.nip01Core + +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.collections.flatten +import kotlin.collections.mapNotNull + +fun filterPictureAndVideoGeohash( + relay: NormalizedRelayUrl, + geotags: Set, + since: Long? = null, +): List { + val geoHashes = geotags.sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = PictureAndVideoKinds, + tags = mapOf("g" to geoHashes), + limit = 400, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = PictureAndVideoLegacyKinds, + tags = + mapOf( + "g" to geoHashes, + "m" to LegacyMimeTypes, + ), + limit = 200, + since = since, + ), + ), + ) +} + +fun filterPictureAndVideoByGeohash( + geoSet: LocationTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set.mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterPictureAndVideoGeohash( + relay = it.key, + geotags = it.value.geotags, + since = since?.get(it.key)?.time, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt new file mode 100644 index 000000000..12dc9003e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2025 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.subassemblies.nip01Core + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten + +fun filterPictureAndVideoHashtag( + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long? = null, +): List { + val hashtags = hashtags.flatMap(::hashtagAlts).distinct().sorted() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = PictureAndVideoKinds, + tags = mapOf("t" to hashtags), + limit = 400, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = PictureAndVideoLegacyKinds, + tags = + mapOf( + "t" to hashtags, + "m" to LegacyMimeTypes, + ), + limit = 200, + since = since, + ), + ), + ) +} + +fun filterPictureAndVideoByHashtag( + hashSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (hashSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return hashSet.set.mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterPictureAndVideoHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt new file mode 100644 index 000000000..674dd199d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2025 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.subassemblies.nip01Core + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypeMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +fun filterPictureAndVideoGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.flatMap { + val since = since?.get(it.key)?.time + listOf( + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = PictureAndVideoKinds, + limit = 200, + since = since, + ), + ), + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = PictureAndVideoLegacyKinds, + tags = LegacyMimeTypeMap, + limit = 200, + since = since, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt new file mode 100644 index 000000000..7b490dcad --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt @@ -0,0 +1,108 @@ +/** + * Copyright (c) 2025 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.subassemblies.nip65Follows + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypeMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten +import kotlin.collections.mapNotNull + +fun filterPictureAndVideoAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = PictureAndVideoKinds, + limit = 400, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = PictureAndVideoLegacyKinds, + tags = LegacyMimeTypeMap, + limit = 200, + since = since, + ), + ), + ) +} + +fun filterPictureAndVideoByAuthors( + authorSet: AuthorsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return authorSet.set.mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPictureAndVideoAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterPictureAndVideoByAuthors( + authorSet: MutedAuthorsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return authorSet.set.mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPictureAndVideoAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByFollows.kt new file mode 100644 index 000000000..55bb3deae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByFollows.kt @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2025 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.subassemblies.nip65Follows + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core.filterPictureAndVideoGeohash +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core.filterPictureAndVideoHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip72Communities.filterPictureAndVideoAllCommunities +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import kotlin.collections.flatten + +fun filterPictureAndVideoByFollows( + followsSet: AllFollowsByOutboxTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterPictureAndVideoAuthors(relay, it, since) + }, + it.value.geotags?.let { + filterPictureAndVideoGeohash(relay, it, since) + }, + it.value.hashtags?.let { + filterPictureAndVideoHashtag(relay, it, since) + }, + it.value.communities?.let { + filterPictureAndVideoAllCommunities(relay, it, since) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt new file mode 100644 index 000000000..08071cb90 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt @@ -0,0 +1,117 @@ +/** + * Copyright (c) 2025 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.subassemblies.nip72Communities + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKTags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import kotlin.collections.flatten + +fun filterPictureAndVideoAllCommunities( + relay: NormalizedRelayUrl, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to PictureAndVideoKTags, + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("a" to communityList), + kinds = PictureAndVideoKinds, + limit = 300, + since = since, + ), + ), + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to listOf(FileHeaderEvent.KIND.toString(), FileStorageHeaderEvent.KIND.toString()), + ), + limit = 300, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = + mapOf( + "a" to communityList, + "m" to LegacyMimeTypes, + ), + kinds = PictureAndVideoLegacyKinds, + limit = 300, + since = since, + ), + ), + ) +} + +fun filterPictureAndVideoByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set.mapNotNull { + filterPictureAndVideoAllCommunities( + relay = it.key, + communities = it.value.communities, + since = since?.get(it.key)?.time, + ) + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt new file mode 100644 index 000000000..61aee43e2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt @@ -0,0 +1,124 @@ +/** + * Copyright (c) 2025 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.subassemblies.nip72Communities + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKTags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKTags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten + +fun filterPictureAndVideoCommunity( + relay: NormalizedRelayUrl, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authors = authors?.sorted() + return listOf( + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to PictureAndVideoKTags, + ), + limit = 500, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = mapOf("a" to listOf(community)), + kinds = PictureAndVideoKinds, + limit = 500, + since = since, + ), + ), + // approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to PictureAndVideoLegacyKTags, + ), + limit = 500, + since = since, + ), + ), + // not approved + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + tags = + mapOf( + "a" to listOf(community), + "m" to LegacyMimeTypes, + ), + kinds = PictureAndVideoLegacyKinds, + limit = 500, + since = since, + ), + ), + ) +} + +fun filterPictureAndVideoByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + since: SincePerRelayMap?, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + val defaultSince = TimeUtils.oneWeekAgo() + + return communitySet.set.mapNotNull { + filterPictureAndVideoCommunity( + relay = it.key, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + }.flatten() +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Constants.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Constants.kt deleted file mode 100644 index 720a302c7..000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Constants.kt +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Copyright (c) 2025 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 com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter - -object Constants { - val activeTypesFollows = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS) - val activeTypesChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS) - val activeTypesGlobalChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) - val activeTypesSearch = setOf(FeedType.SEARCH) - - val defaultRelays = - arrayOf( - // Free relays for only DMs, Chats and Follows due to the amount of spam - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.bitcoiner.social"), read = true, write = true, feedTypes = activeTypesChats), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.nostr.bg"), read = true, write = true, feedTypes = activeTypesChats), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.oxtr.dev"), read = true, write = true, feedTypes = activeTypesChats), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.fmt.wiz.biz"), read = true, write = false, feedTypes = activeTypesChats), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.damus.io"), read = true, write = true, feedTypes = activeTypesFollows), - // Global - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.mom"), read = true, write = true, feedTypes = activeTypesGlobalChats), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nos.lol"), read = true, write = true, feedTypes = activeTypesGlobalChats), - // Paid relays - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostrelites.org"), read = true, write = false, feedTypes = activeTypesGlobalChats), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.wine"), read = true, write = false, feedTypes = activeTypesGlobalChats), - // Supporting NIP-50 - RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.nostr.band"), read = true, write = false, feedTypes = activeTypesSearch), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://nostr.wine"), read = true, write = false, feedTypes = activeTypesSearch), - RelaySetupInfo(RelayUrlFormatter.normalize("wss://relay.noswhere.com"), read = true, write = false, feedTypes = activeTypesSearch), - ) - - val defaultSearchRelaySet = - setOf( - RelayUrlFormatter.normalize("wss://relay.nostr.band"), - RelayUrlFormatter.normalize("wss://nostr.wine"), - RelayUrlFormatter.normalize("wss://relay.noswhere.com"), - ) -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt deleted file mode 100644 index 9fb98f556..000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/NostrClient.kt +++ /dev/null @@ -1,475 +0,0 @@ -/** - * Copyright (c) 2025 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.nip01Core.relay.sockets.WebsocketBuilderFactory -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import java.util.UUID -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -/** - * The Nostr Client manages a relay pool - */ -class NostrClient( - private val websocketBuilder: WebsocketBuilderFactory, -) : RelayPool.Listener { - private val relayPool: RelayPool = RelayPool() - private val activeSubscriptions: MutableSubscriptionCache = MutableSubscriptionCache() - private var listeners = setOf() - - 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) - - // Reconnects all relays that may have disconnected - fun reconnect() = relayPool.requestAndWatch() - - @Synchronized - fun reconnect( - relays: Array?, - onlyIfChanged: Boolean = false, - ) { - Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays: \n${relays?.joinToString("\n") { it.url + " " + it.forceProxy + " " + it.read + " " + it.write + " " + it.feedTypes.joinToString(",") { it.name } }}") - checkNotInMainThread() - - if (onlyIfChanged) { - if (!isSameRelaySetConfig(relays)) { - if (this.relayPool.availableRelays() > 0) { - relayPool.disconnect() - relayPool.unregister(this) - relayPool.unloadRelays() - } - - if (relays != null) { - relayPool.register(this) - relayPool.loadRelays(relays.map(::buildRelay)) - relayPool.requestAndWatch() - } - } else { - // Reconnects all relays that may have disconnected - relayPool.requestAndWatch() - } - } else { - if (this.relayPool.availableRelays() > 0) { - relayPool.disconnect() - relayPool.unregister(this) - relayPool.unloadRelays() - } - - if (relays != null) { - relayPool.register(this) - relayPool.loadRelays(relays.map(::buildRelay)) - relayPool.requestAndWatch() - } - } - } - - fun isSameRelaySetConfig(newRelayConfig: Array?): Boolean { - if (relayPool.availableRelays() != newRelayConfig?.size) return false - - newRelayConfig.forEach { - val relay = relayPool.getRelay(it.url) ?: return false - if (!relay.isSameRelayConfig(it)) return false - } - - return true - } - - fun sendFilter( - subscriptionId: String = UUID.randomUUID().toString().substring(0..10), - filters: List = listOf(), - ) { - checkNotInMainThread() - - activeSubscriptions.add(subscriptionId, filters) - relayPool.sendFilter(subscriptionId, filters) - } - - fun sendFilterAndStopOnFirstResponse( - subscriptionId: String = UUID.randomUUID().toString().substring(0..10), - filters: List = listOf(), - onResponse: (Event) -> Unit, - ) { - checkNotInMainThread() - - subscribe( - object : Listener { - override fun onEvent( - event: Event, - subId: String, - relay: Relay, - arrivalTime: Long, - afterEOSE: Boolean, - ) { - if (subId == subscriptionId) { - unsubscribe(this) - close(subscriptionId) - - onResponse(event) - } - } - }, - ) - - activeSubscriptions.add(subscriptionId, filters) - relayPool.sendFilter(subscriptionId, filters) - } - - @OptIn(DelicateCoroutinesApi::class) - suspend fun sendAndWaitForResponse( - signedEvent: Event, - relay: String? = null, - forceProxy: Boolean = false, - feedTypes: Set? = null, - relayList: List? = null, - onDone: (() -> Unit)? = null, - additionalListener: Listener? = null, - timeoutInSeconds: Long = 15, - ): Boolean { - checkNotInMainThread() - - val size = if (relay != null) 1 else relayList?.size ?: relayPool.availableRelays() - val latch = CountDownLatch(size) - val relayErrors = mutableMapOf() - var result = false - - Log.d("sendAndWaitForResponse", "Waiting for $size responses") - - val subscription = - object : Listener { - override fun onError( - error: Error, - subscriptionId: String, - relay: Relay, - ) { - relayErrors[relay.url]?.let { - latch.countDown() - } - Log.d("sendAndWaitForResponse", "onError Error from relay ${relay.url} count: ${latch.count} error: $error") - } - - override fun onEOSE( - relay: Relay, - subscriptionId: String, - arrivalTime: Long, - ) { - latch.countDown() - Log.d("sendAndWaitForResponse", "onEOSE relay ${relay.url} count: ${latch.count}") - } - - override fun onRelayStateChange( - type: RelayState, - relay: Relay, - ) { - if (type == RelayState.DISCONNECTED) { - latch.countDown() - } - if (type == RelayState.CONNECTED) { - Log.d("sendAndWaitForResponse", "${type.name} Sending event to relay ${relay.url} count: ${latch.count}") - relay.send(signedEvent) - } - Log.d("sendAndWaitForResponse", "onRelayStateChange ${type.name} from relay ${relay.url} count: ${latch.count}") - } - - override fun onSendResponse( - eventId: String, - success: Boolean, - message: String, - relay: Relay, - ) { - if (eventId == signedEvent.id) { - if (success) { - result = true - } - latch.countDown() - Log.d("sendAndWaitForResponse", "onSendResponse Received response for $eventId from relay ${relay.url} count: ${latch.count} message $message success $success") - } - } - } - - subscribe(subscription) - additionalListener?.let { subscribe(it) } - - val job = - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { - if (relayList != null) { - send(signedEvent, relayList) - } else if (relay == null) { - send(signedEvent) - } else { - sendSingle(signedEvent, RelaySetupInfoToConnect(relay, forceProxy, true, true, emptySet()), onDone ?: {}) - } - } - job.join() - - runBlocking { - latch.await(timeoutInSeconds, TimeUnit.SECONDS) - } - Log.d("sendAndWaitForResponse", "countdown finished") - unsubscribe(subscription) - additionalListener?.let { unsubscribe(it) } - return result - } - - fun getAll(): List = relayPool.getAll() - - fun sendFilterOnlyIfDisconnected( - subscriptionId: String = UUID.randomUUID().toString().substring(0..10), - filters: List = listOf(), - ) { - checkNotInMainThread() - - activeSubscriptions.add(subscriptionId, filters) - relayPool.connectAndSendFiltersIfDisconnected() - } - - fun sendIfExists( - signedEvent: Event, - connectedRelay: Relay, - ) { - checkNotInMainThread() - - relayPool.getRelays(connectedRelay.url).forEach { - it.send(signedEvent) - } - } - - fun sendSingle( - signedEvent: Event, - relayTemplate: RelaySetupInfoToConnect, - onDone: (() -> Unit), - ) { - checkNotInMainThread() - - relayPool.runCreatingIfNeeded(buildRelay(relayTemplate), onDone = onDone) { - it.send(signedEvent) - } - } - - fun send(signedEvent: Event) { - checkNotInMainThread() - relayPool.send(signedEvent) - } - - fun send( - signedEvent: Event, - relayList: List, - ) { - checkNotInMainThread() - - relayPool.sendToSelectedRelays(relayList, signedEvent) - } - - fun sendPrivately( - signedEvent: Event, - relayList: List, - ) { - checkNotInMainThread() - - relayList.forEach { relayTemplate -> - relayPool.runCreatingIfNeeded(buildRelay(relayTemplate)) { - it.sendOverride(signedEvent) - } - } - } - - fun close(subscriptionId: String) { - relayPool.close(subscriptionId) - activeSubscriptions.remove(subscriptionId) - } - - fun isActive(subscriptionId: String): Boolean = activeSubscriptions.isActive(subscriptionId) - - @OptIn(DelicateCoroutinesApi::class) - override fun onEvent( - event: Event, - subscriptionId: String, - relay: Relay, - arrivalTime: Long, - afterEOSE: Boolean, - ) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - listeners.forEach { it.onEvent(event, subscriptionId, relay, arrivalTime, afterEOSE) } - } - - override fun onEOSE( - relay: Relay, - subscriptionId: String, - arrivalTime: Long, - ) { - listeners.forEach { it.onEOSE(relay, subscriptionId, arrivalTime) } - } - - override fun onRelayStateChange( - type: RelayState, - relay: Relay, - ) { - listeners.forEach { it.onRelayStateChange(type, relay) } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onSendResponse( - eventId: String, - success: Boolean, - message: String, - relay: Relay, - ) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - listeners.forEach { it.onSendResponse(eventId, success, message, relay) } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onAuth( - relay: Relay, - challenge: String, - ) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - listeners.forEach { it.onAuth(relay, challenge) } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onNotify( - relay: Relay, - description: String, - ) { - // Releases the Web thread for the new payload. - // May need to add a processing queue if processing new events become too costly. - listeners.forEach { it.onNotify(relay, description) } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onSend( - relay: Relay, - msg: String, - success: Boolean, - ) { - listeners.forEach { it.onSend(relay, msg, success) } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onBeforeSend( - relay: Relay, - event: Event, - ) { - listeners.forEach { it.onBeforeSend(relay, event) } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onError( - error: Error, - subscriptionId: String, - relay: Relay, - ) { - listeners.forEach { it.onError(error, subscriptionId, relay) } - } - - fun subscribe(listener: Listener) { - listeners = listeners.plus(listener) - } - - fun isSubscribed(listener: Listener): Boolean = listeners.contains(listener) - - fun unsubscribe(listener: Listener) { - listeners = listeners.minus(listener) - } - - fun allSubscriptions(): Map> = activeSubscriptions.allSubscriptions() - - fun getSubscriptionFilters(subId: String): List = activeSubscriptions.getSubscriptionFilters(subId) - - fun getSubscriptionFiltersOrNull(subId: String): List? = activeSubscriptions.getSubscriptionFiltersOrNull(subId) - - fun connectedRelays() = relayPool.connectedRelays() - - fun relayStatusFlow() = relayPool.statusFlow - - interface Listener { - /** A new message was received */ - open fun onEvent( - event: Event, - subscriptionId: String, - relay: Relay, - arrivalTime: Long, - afterEOSE: Boolean, - ) = Unit - - /** Connected to or disconnected from a relay */ - open fun onEOSE( - relay: Relay, - subscriptionId: String, - arrivalTime: Long, - ) = Unit - - /** Connected to or disconnected from a relay */ - open fun onRelayStateChange( - type: RelayState, - relay: Relay, - ) = Unit - - /** When an relay saves or rejects a new event. */ - open fun onSendResponse( - eventId: String, - success: Boolean, - message: String, - relay: Relay, - ) = Unit - - open fun onAuth( - relay: Relay, - challenge: String, - ) = Unit - - open fun onNotify( - relay: Relay, - description: String, - ) = Unit - - open fun onSend( - relay: Relay, - msg: String, - success: Boolean, - ) = Unit - - open fun onBeforeSend( - relay: Relay, - event: Event, - ) = Unit - - open fun onError( - error: Error, - subscriptionId: String, - relay: Relay, - ) = 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 deleted file mode 100644 index aeb7cf632..000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt +++ /dev/null @@ -1,274 +0,0 @@ -/** - * Copyright (c) 2025 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 com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.RelayState -import com.vitorpamplona.quartz.nip01Core.relay.SimpleClientRelay -import com.vitorpamplona.quartz.nip01Core.relay.SubscriptionCollection -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilderFactory -import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent - -enum class FeedType { - FOLLOWS, - PUBLIC_CHATS, - PRIVATE_DMS, - GLOBAL, - SEARCH, - WALLET_CONNECT, -} - -val ALL_FEED_TYPES = - setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH) - -val COMMON_FEED_TYPES = - setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) - -val EVENT_FINDER_TYPES = - setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL) - -class RelaySubFilter( - val url: String, - val activeTypes: Set, - val subs: SubscriptionCache, -) : SubscriptionCollection { - fun isMatch(filter: TypedFilter) = activeTypes.any { it in filter.types } && filter.isValidFor(url) - - fun match(filters: List): Boolean = - filters.any { filter -> - isMatch(filter) - } - - override fun isActive(subscriptionId: String): Boolean = subs.isActive(subscriptionId) && match(subs.getSubscriptionFilters(subscriptionId)) - - override fun getFilters(subscriptionId: String) = filter(subs.getSubscriptionFilters(subscriptionId)) - - override fun allSubscriptions(): List = - subs.allSubscriptions().mapNotNull { filter -> - val filters = filter(filter.value) - if (filters.isNotEmpty()) { - com.vitorpamplona.quartz.nip01Core.relay - .Subscription(filter.key, filters) - } else { - null - } - } - - override fun match( - subscriptionId: String, - event: Event, - ): Boolean = subs.getSubscriptionFilters(subscriptionId).any { it.filter.match(event, url) } - - fun filter(filters: List): List = - filters.mapNotNull { filter -> - if (isMatch(filter)) { - filter.filter.toRelay(url) - } else { - null - } - } -} - -class Relay( - val url: String, - val read: Boolean = true, - val write: Boolean = true, - val forceProxy: Boolean = false, - val activeTypes: Set, - socketBuilderFactory: WebsocketBuilderFactory, - subs: SubscriptionCache, -) : SimpleClientRelay.Listener { - 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 brief = RelayBriefInfoCache.get(url) - - fun register(listener: Listener) { - listeners = listeners.plus(listener) - } - - fun unregister(listener: Listener) { - listeners = listeners.minus(listener) - } - - fun isConnected() = inner.isConnected() - - fun connect() = inner.connect() - - fun connectAndRunAfterSync(onConnected: () -> Unit) { - // BRB is crashing OkHttp Deflater object :( - if (url.contains("brb.io")) return - - inner.connectAndRunAfterSync(onConnected) - } - - fun sendOutbox() = inner.sendOutbox() - - fun disconnect() = inner.disconnect() - - fun sendFilter( - requestId: String, - filters: List, - ) { - if (read) { - inner.sendRequest(requestId, relaySubFilter.filter(filters)) - } - } - - fun connectAndSendFiltersIfDisconnected() = inner.connectAndSendFiltersIfDisconnected() - - fun renewFilters() = inner.renewSubscriptions() - - fun sendOverride(signedEvent: Event) = inner.send(signedEvent) - - fun send(signedEvent: Event) { - if (signedEvent is RelayAuthEvent || write) { - inner.send(signedEvent) - } - } - - fun close(subscriptionId: String) = inner.close(subscriptionId) - - fun isSameRelayConfig(other: RelaySetupInfoToConnect): Boolean = - url == other.url && - forceProxy == other.forceProxy && - write == other.write && - read == other.read && - activeTypes == other.feedTypes - - override fun onEvent( - relay: SimpleClientRelay, - subscriptionId: String, - event: Event, - time: Long, - afterEOSE: Boolean, - ) = listeners.forEach { it.onEvent(this, subscriptionId, event, time, afterEOSE) } - - override fun onError( - relay: SimpleClientRelay, - subscriptionId: String, - error: Error, - ) = listeners.forEach { it.onError(this, subscriptionId, error) } - - override fun onEOSE( - relay: SimpleClientRelay, - subscriptionId: String, - time: Long, - ) = listeners.forEach { it.onEOSE(this, subscriptionId, time) } - - override fun onRelayStateChange( - relay: SimpleClientRelay, - type: RelayState, - ) = listeners.forEach { it.onRelayStateChange(this, type) } - - override fun onSendResponse( - relay: SimpleClientRelay, - eventId: String, - success: Boolean, - message: String, - ) = listeners.forEach { it.onSendResponse(this, eventId, success, message) } - - override fun onAuth( - relay: SimpleClientRelay, - challenge: String, - ) = listeners.forEach { it.onAuth(this, challenge) } - - override fun onNotify( - relay: SimpleClientRelay, - description: String, - ) = listeners.forEach { it.onNotify(this, description) } - - override fun onClosed( - relay: SimpleClientRelay, - subscriptionId: String, - message: String, - ) { } - - override fun onSend( - relay: SimpleClientRelay, - msg: String, - success: Boolean, - ) = listeners.forEach { it.onSend(this, msg, success) } - - override fun onBeforeSend( - relay: SimpleClientRelay, - event: Event, - ) = listeners.forEach { it.onBeforeSend(this, event) } - - interface Listener { - fun onEvent( - relay: Relay, - subscriptionId: String, - event: Event, - time: Long, - afterEOSE: Boolean, - ) - - fun onEOSE( - relay: Relay, - subscriptionId: String, - time: Long, - ) - - fun onError( - relay: Relay, - subscriptionId: String, - error: Error, - ) - - fun onSendResponse( - relay: Relay, - eventId: String, - success: Boolean, - message: String, - ) - - fun onAuth( - relay: Relay, - challenge: String, - ) - - fun onRelayStateChange( - relay: Relay, - type: RelayState, - ) - - /** Relay sent a notification */ - fun onNotify( - relay: Relay, - description: String, - ) - - fun onBeforeSend( - relay: Relay, - event: Event, - ) - - fun onSend( - relay: Relay, - msg: String, - success: Boolean, - ) - } -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt deleted file mode 100644 index 821c9119e..000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt +++ /dev/null @@ -1,311 +0,0 @@ -/** - * Copyright (c) 2025 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 androidx.compose.runtime.Immutable -import com.vitorpamplona.ammolite.service.checkNotInMainThread -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.RelayState -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.launch - -/** - * RelayPool manages the connection to multiple Relays and lets consumers deal with simple events. - */ -class RelayPool : Relay.Listener { - private var relays = listOf() - private var listeners = setOf() - - // Backing property to avoid flow emissions from other classes - private var lastStatus = RelayPoolStatus(0, 0) - private val _statusFlow = - MutableSharedFlow(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - val statusFlow: SharedFlow = _statusFlow.asSharedFlow() - - fun availableRelays(): Int = relays.size - - fun connectedRelays(): Int = relays.count { it.isConnected() } - - fun getRelay(url: String): Relay? = relays.firstOrNull { it.url == url } - - fun getRelays(url: String): List = relays.filter { it.url == url } - - fun getAll() = relays - - fun runCreatingIfNeeded( - relay: Relay, - timeout: Long = 60000, - onDone: (() -> Unit)? = null, - whenConnected: (Relay) -> Unit, - ) { - synchronized(this) { - val matching = getRelays(relay.url) - if (matching.isNotEmpty()) { - matching.forEach { whenConnected(it) } - } else { - addRelay(relay) - - relay.connectAndRunAfterSync { - whenConnected(relay) - - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { - delay(timeout) // waits for a reply - relay.disconnect() - removeRelay(relay) - - if (onDone != null) { - onDone() - } - } - } - } - } - } - - fun loadRelays(relayList: List) { - check(relayList.isNotEmpty()) { "Relay list should never be empty" } - relayList.forEach { addRelayInner(it) } - updateStatus() - } - - fun unloadRelays() { - relays.forEach { it.unregister(this) } - relays = listOf() - } - - fun requestAndWatch() { - checkNotInMainThread() - - relays.forEach { it.connect() } - } - - fun sendFilter( - subscriptionId: String, - filters: List, - ) { - relays.forEach { relay -> - relay.sendFilter(subscriptionId, filters) - } - } - - fun connectAndSendFiltersIfDisconnected() { - relays.forEach { it.connectAndSendFiltersIfDisconnected() } - } - - fun sendToSelectedRelays( - list: List, - signedEvent: Event, - ) { - list.forEach { relay -> relays.filter { it.url == relay.url }.forEach { it.sendOverride(signedEvent) } } - } - - fun send(signedEvent: Event) { - relays.forEach { it.send(signedEvent) } - } - - fun sendOverride(signedEvent: Event) { - relays.forEach { it.sendOverride(signedEvent) } - } - - fun close(subscriptionId: String) { - relays.forEach { it.close(subscriptionId) } - } - - fun disconnect() { - relays.forEach { it.disconnect() } - } - - fun addRelay(relay: Relay) { - addRelayInner(relay) - updateStatus() - } - - private fun addRelayInner(relay: Relay) { - relay.register(this) - relays += relay - } - - fun removeRelay(relay: Relay) { - relay.unregister(this) - relays = relays.minus(relay) - updateStatus() - } - - fun register(listener: Listener) { - listeners = listeners.plus(listener) - } - - fun unregister(listener: Listener) { - listeners = listeners.minus(listener) - } - - interface Listener { - fun onEvent( - event: Event, - subscriptionId: String, - relay: Relay, - arrivalTime: Long, - afterEOSE: Boolean, - ) - - fun onEOSE( - relay: Relay, - subscriptionId: String, - arrivalTime: Long, - ) - - fun onRelayStateChange( - type: RelayState, - relay: Relay, - ) - - fun onSendResponse( - eventId: String, - success: Boolean, - message: String, - relay: Relay, - ) - - fun onAuth( - relay: Relay, - challenge: String, - ) - - fun onNotify( - relay: Relay, - description: String, - ) - - fun onSend( - relay: Relay, - msg: String, - success: Boolean, - ) - - fun onBeforeSend( - relay: Relay, - event: Event, - ) - - fun onError( - error: Error, - subscriptionId: String, - relay: Relay, - ) - } - - override fun onEvent( - relay: Relay, - subscriptionId: String, - event: Event, - arrivalTime: Long, - afterEOSE: Boolean, - ) { - listeners.forEach { it.onEvent(event, subscriptionId, relay, arrivalTime, afterEOSE) } - } - - override fun onError( - relay: Relay, - subscriptionId: String, - error: Error, - ) { - listeners.forEach { it.onError(error, subscriptionId, relay) } - updateStatus() - } - - override fun onEOSE( - relay: Relay, - subscriptionId: String, - arrivalTime: Long, - ) { - listeners.forEach { it.onEOSE(relay, subscriptionId, arrivalTime) } - updateStatus() - } - - override fun onRelayStateChange( - relay: Relay, - type: RelayState, - ) { - listeners.forEach { it.onRelayStateChange(type, relay) } - } - - override fun onSendResponse( - relay: Relay, - eventId: String, - success: Boolean, - message: String, - ) { - listeners.forEach { it.onSendResponse(eventId, success, message, relay) } - } - - override fun onAuth( - relay: Relay, - challenge: String, - ) { - listeners.forEach { it.onAuth(relay, challenge) } - } - - override fun onNotify( - relay: Relay, - description: String, - ) { - listeners.forEach { it.onNotify(relay, description) } - } - - override fun onSend( - relay: Relay, - msg: String, - success: Boolean, - ) { - listeners.forEach { it.onSend(relay, msg, success) } - } - - override fun onBeforeSend( - relay: Relay, - event: Event, - ) { - listeners.forEach { it.onBeforeSend(relay, event) } - } - - private fun updateStatus() { - val connected = connectedRelays() - val available = availableRelays() - if (lastStatus.connected != connected || lastStatus.available != available) { - lastStatus = RelayPoolStatus(connected, available) - _statusFlow.tryEmit(lastStatus) - } - } -} - -@Immutable -data class RelayPoolStatus( - val connected: Int, - val available: Int, - val isConnected: Boolean = connected > 0, -) 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 deleted file mode 100644 index 8bfb03ee4..000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/NostrDataSource.kt +++ /dev/null @@ -1,276 +0,0 @@ -/** - * Copyright (c) 2025 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 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, - arrivalTime: Long, - afterEOSE: Boolean, - ) { - if (subscriptions.contains(subscriptionId)) { - stats.add(subscriptionId, event.kind) - - consume(event, relay) - - if (afterEOSE) { - markAsEOSE(subscriptionId, relay, arrivalTime) - } - } - } - - override fun onEOSE( - relay: Relay, - subscriptionId: String, - arrivalTime: Long, - ) { - if (subscriptions.contains(subscriptionId)) { - markAsEOSE(subscriptionId, relay, arrivalTime) - } - } - - 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, - arrivalTime: Long, - ) { - subscriptions[subscriptionId]?.callEose( - arrivalTime, - 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/Subscription.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/Subscription.kt index 91f56e7bc..a5595470f 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/Subscription.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/Subscription.kt @@ -20,116 +20,46 @@ */ package com.vitorpamplona.ammolite.relays.datasources -import com.vitorpamplona.ammolite.relays.TypedFilter -import com.vitorpamplona.ammolite.relays.filters.NormalFilter -import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter -import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter -import java.util.UUID +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.collections.forEachIndexed data class Subscription( - val id: String = UUID.randomUUID().toString().substring(0, 4), - val onEose: ((time: Long, relayUrl: String) -> Unit)? = null, + val id: String = newSubId(), + val onEose: ((time: Long, relayUrl: NormalizedRelayUrl) -> Unit)? = null, ) { - var typedFilters: List? = null // Inactive when null + var relayBasedFilters: List? = null // Inactive when null fun reset() { - typedFilters = null + relayBasedFilters = null } fun callEose( time: Long, - relay: String, + relay: NormalizedRelayUrl, ) { onEose?.let { it(time, relay) } } - fun hasChangedFiltersFrom(otherFilters: List?): Boolean { - if (typedFilters == null && otherFilters == null) return false - if (typedFilters?.size != otherFilters?.size) return true + fun hasChangedFiltersFrom(otherFilters: List?): Boolean { + if (relayBasedFilters == null && otherFilters == null) return false + if (relayBasedFilters?.size != otherFilters?.size) return true - typedFilters?.forEachIndexed { index, typedFilter -> + relayBasedFilters?.forEachIndexed { index, relaySetFilter -> val otherFilter = otherFilters?.getOrNull(index) ?: return true - if (typedFilter.filter is SincePerRelayFilter && otherFilter.filter is SincePerRelayFilter) { - return isDifferent(typedFilter.filter, otherFilter.filter) - } + if (relaySetFilter.relay != otherFilter.relay) return true - if (typedFilter.filter is SinceAuthorPerRelayFilter && otherFilter.filter is SinceAuthorPerRelayFilter) { - return isDifferent(typedFilter.filter, otherFilter.filter) - } - - if (typedFilter.filter is NormalFilter && otherFilter.filter is NormalFilter) { - return isDifferent(typedFilter.filter, otherFilter.filter) - } - - return true + return isDifferent(relaySetFilter.filter, otherFilter.filter) } return false } fun isDifferent( - filter1: SincePerRelayFilter, - filter2: SincePerRelayFilter, - ): Boolean { - // Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed. - // fast check - if (filter1.authors?.size != filter2.authors?.size || - filter1.ids?.size != filter2.ids?.size || - filter1.tags?.size != filter2.tags?.size || - filter1.kinds?.size != filter2.kinds?.size || - filter1.limit != filter2.limit || - filter1.search?.length != filter2.search?.length || - filter1.until != filter2.until - ) { - return true - } - - // deep check - if (filter1.ids != filter2.ids || - filter1.authors != filter2.authors || - filter1.tags != filter2.tags || - filter1.kinds != filter2.kinds || - filter1.search != filter2.search - ) { - return true - } - - return false - } - - fun isDifferent( - filter1: SinceAuthorPerRelayFilter, - filter2: SinceAuthorPerRelayFilter, - ): Boolean { - // Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed. - // fast check - if (filter1.authors?.size != filter2.authors?.size || - filter1.ids?.size != filter2.ids?.size || - filter1.tags?.size != filter2.tags?.size || - filter1.kinds?.size != filter2.kinds?.size || - filter1.limit != filter2.limit || - filter1.search?.length != filter2.search?.length || - filter1.until != filter2.until - ) { - return true - } - - // deep check - if (filter1.ids != filter2.ids || - filter1.authors != filter2.authors || - filter1.tags != filter2.tags || - filter1.kinds != filter2.kinds || - filter1.search != filter2.search - ) { - return true - } - - return false - } - - fun isDifferent( - filter1: NormalFilter, - filter2: NormalFilter, + filter1: Filter, + filter2: Filter, ): Boolean { // Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed. // fast check diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionController.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionController.kt index daf793917..5dd295a5d 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionController.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionController.kt @@ -21,10 +21,12 @@ package com.vitorpamplona.ammolite.relays.datasources 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.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import java.util.concurrent.atomic.AtomicBoolean @@ -44,36 +46,36 @@ class SubscriptionController( val stats = SubscriptionStats() private val clientListener = - object : NostrClient.Listener { + object : IRelayClientListener { override fun onEvent( + relay: IRelayClient, + subId: String, event: Event, - subscriptionId: String, - relay: Relay, arrivalTime: Long, afterEOSE: Boolean, ) { - if (subscriptions.contains(subscriptionId)) { - stats.add(subscriptionId, event.kind) + if (subscriptions.contains(subId)) { + stats.add(subId, event.kind) if (afterEOSE) { - runAfterEOSE(subscriptionId, relay, arrivalTime) + runAfterEOSE(subId, relay, arrivalTime) } } } override fun onEOSE( - relay: Relay, - subscriptionId: String, + relay: IRelayClient, + subId: String, arrivalTime: Long, ) { - if (subscriptions.contains(subscriptionId)) { - runAfterEOSE(subscriptionId, relay, arrivalTime) + if (subscriptions.contains(subId)) { + runAfterEOSE(subId, relay, arrivalTime) } } } private fun runAfterEOSE( subscriptionId: String, - relay: Relay, + relay: IRelayClient, arrivalTime: Long, ) { subscriptions[subscriptionId]?.callEose(arrivalTime, relay.url) @@ -109,7 +111,7 @@ class SubscriptionController( fun getSub(subId: String) = subscriptions.get(subId) - fun requestNewSubscription(onEOSE: ((Long, String) -> Unit)? = null): Subscription = subscriptions.newSub(onEOSE) + fun requestNewSubscription(onEOSE: ((Long, NormalizedRelayUrl) -> Unit)? = null): Subscription = subscriptions.newSub(onEOSE) fun dismissSubscription(subId: String) = getSub(subId)?.let { dismissSubscription(it) } @@ -163,9 +165,9 @@ class SubscriptionController( fun updateRelaysIfNeeded( updatedSubscription: Subscription, - currentFilters: List?, + currentFilters: List?, ) { - val updatedSubscriptionNewFilters = updatedSubscription.typedFilters + val updatedSubscriptionNewFilters = updatedSubscription.relayBasedFilters val isActive = client.isActive(updatedSubscription.id) 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 index 404a8536f..c40e3a42a 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionSet.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/SubscriptionSet.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.ammolite.relays.datasources +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + class SubscriptionSet { private var subscriptions = mapOf() @@ -35,11 +37,11 @@ class SubscriptionSet { fun remove(sub: Subscription) = remove(sub.id) - fun newSub(onEOSE: ((Long, String) -> Unit)? = null): Subscription = Subscription(onEose = onEOSE).also { add(it) } + fun newSub(onEOSE: ((Long, NormalizedRelayUrl) -> 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 } + fun actives() = subscriptions.values.filter { it.relayBasedFilters != null } } diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/EOSETime.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/MutableTime.kt similarity index 80% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/EOSETime.kt rename to ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/MutableTime.kt index f07ebed6a..155f628c5 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/EOSETime.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/MutableTime.kt @@ -23,14 +23,25 @@ package com.vitorpamplona.ammolite.relays.filters /* * Wrapper class to allow changing in EOSE without modifying the list it is included within */ -class EOSETime( - var time: Long, -) { +class MutableTime(startTime: Long) { + var time: Long = startTime + private set + override fun toString(): String = time.toString() - fun update(newTime: Long) { + fun updateIfNewer(newTime: Long) { if (newTime > time) { time = newTime } } + + fun updateIfOlder(newTime: Long) { + if (newTime < time) { + time = newTime + } + } + + fun minus(delta: Int) = MutableTime(time - delta) + + fun copy() = MutableTime(time) } diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/NormalFilter.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/NormalFilter.kt deleted file mode 100644 index b837f826c..000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/NormalFilter.kt +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Copyright (c) 2025 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.filters - -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterMatcher -import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterSerializer - -/** - * This is a nostr filter with per-relay authors list and since parameters - */ -class NormalFilter( - val ids: List? = null, - val authors: List? = null, - val kinds: List? = null, - val tags: Map>? = null, - val since: Long? = null, - val until: Long? = null, - val limit: Int? = null, - val search: String? = null, -) : IPerRelayFilter { - override fun isValidFor(url: String) = true - - override fun toRelay(forRelay: String) = Filter(ids, authors, kinds, tags, since, until, limit, search) - - override fun toJson(forRelay: String) = FilterSerializer.toJson(ids, authors, kinds, tags, since, until, limit, search) - - override fun match( - event: Event, - forRelay: String, - ) = FilterMatcher.match(event, ids, authors, kinds, tags, since, until) - - override fun toDebugJson(): String { - val obj = FilterSerializer.toJsonObject(ids, authors, kinds, tags, since, until, limit, search) - return EventMapper.toJson(obj) - } -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt deleted file mode 100644 index 38abad599..000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Copyright (c) 2025 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.filters - -import com.fasterxml.jackson.databind.node.JsonNodeFactory -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterMatcher -import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterSerializer - -/** - * This is a nostr filter with per-relay authors list and since parameters - */ -class SinceAuthorPerRelayFilter( - val ids: List? = null, - val authors: Map>? = null, - val kinds: List? = null, - val tags: Map>? = null, - val since: Map? = null, - val until: Long? = null, - val limit: Int? = null, - val search: String? = null, -) : IPerRelayFilter { - // This only exists because some relays consider empty arrays as null and return everything. - // So, if there is an author list, but no list for the specific relay or if the list is empty - // don't send it. - override fun isValidFor(forRelay: String) = authors == null || !authors[forRelay].isNullOrEmpty() - - override fun toRelay(forRelay: String) = Filter(ids, authors?.get(forRelay), kinds, tags, since?.get(forRelay)?.time, until, limit, search) - - override fun toJson(forRelay: String): String = FilterSerializer.toJson(ids, authors?.get(forRelay), kinds, tags, since?.get(forRelay)?.time, until, limit, search) - - override fun match( - event: Event, - forRelay: String, - ) = FilterMatcher.match(event, ids, authors?.get(forRelay), kinds, tags, since?.get(forRelay)?.time, until) - - override fun toDebugJson(): String { - val factory = JsonNodeFactory.instance - val obj = FilterSerializer.toJsonObject(ids, null, kinds, tags, null, until, limit, search) - authors?.run { - if (isNotEmpty()) { - val jsonObjectPerRelayAuthors = factory.objectNode() - entries.forEach { relayAuthorPairs -> - jsonObjectPerRelayAuthors.replace( - relayAuthorPairs.key, - factory.arrayNode(relayAuthorPairs.value.size).apply { - relayAuthorPairs.value.forEach { - add(it) - } - }, - ) - } - obj.replace("authors", jsonObjectPerRelayAuthors) - } - } - - since?.run { - if (isNotEmpty()) { - val jsonObjectSince = factory.objectNode() - entries.forEach { sincePairs -> - jsonObjectSince.put(sincePairs.key, "${sincePairs.value}") - } - obj.replace("since", jsonObjectSince) - } - } - return EventMapper.mapper.writeValueAsString(obj) - } -} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SincePerRelayFilter.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SincePerRelayFilter.kt deleted file mode 100644 index 522f368a3..000000000 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SincePerRelayFilter.kt +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Copyright (c) 2025 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.filters - -import com.fasterxml.jackson.databind.node.JsonNodeFactory -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterMatcher -import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterSerializer - -/** - * This is a nostr filter with per-relay authors list and since parameters - */ -class SincePerRelayFilter( - val ids: List? = null, - val authors: List? = null, - val kinds: List? = null, - val tags: Map>? = null, - val since: Map? = null, - val until: Long? = null, - val limit: Int? = null, - val search: String? = null, -) : IPerRelayFilter { - override fun isValidFor(url: String) = true - - override fun toRelay(forRelay: String) = Filter(ids, authors, kinds, tags, since?.get(forRelay)?.time, until, limit, search) - - override fun toJson(forRelay: String) = FilterSerializer.toJson(ids, authors, kinds, tags, since?.get(forRelay)?.time, until, limit, search) - - override fun match( - event: Event, - forRelay: String, - ) = FilterMatcher.match(event, ids, authors, kinds, tags, since?.get(forRelay)?.time, until) - - override fun toDebugJson(): String { - val factory = JsonNodeFactory.instance - val obj = FilterSerializer.toJsonObject(ids, authors, kinds, tags, null, until, limit, search) - - since?.run { - if (isNotEmpty()) { - val jsonObjectSince = factory.objectNode() - entries.forEach { sincePairs -> - jsonObjectSince.put(sincePairs.key, "${sincePairs.value}") - } - obj.replace("since", jsonObjectSince) - } - } - return EventMapper.toJson(obj) - } -} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HintIndexerBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HintIndexerBenchmark.kt index 5afb1b72c..0b8150e21 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HintIndexerBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HintIndexerBenchmark.kt @@ -74,7 +74,7 @@ class HintIndexerBenchmark { val key = keys.random() benchmarkRule.measureRepeated { - indexer.getKey(key) + indexer.hintsForKey(key) } } diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt index 301a0180a..cc26f5549 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt @@ -29,7 +29,7 @@ import java.util.regex.Pattern class Base64Image { companion object { - val pattern = Pattern.compile("data:image/(${RichTextParser.Companion.imageExtensions.joinToString(separator = "|") { it } });base64,([a-zA-Z0-9+/]+={0,2})") + val pattern = Pattern.compile("data:image/(${RichTextParser.imageExtensions.joinToString(separator = "|") { it } });base64,([a-zA-Z0-9+/]+={0,2})") fun isBase64(content: String): Boolean { val matcher = pattern.matcher(content) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 502b1926b..258945d5e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] accompanistAdaptive = "0.37.3" activityCompose = "1.10.1" -agp = "8.10.0" +agp = "8.10.1" android-compileSdk = "35" android-minSdk = "26" android-targetSdk = "35" @@ -109,6 +109,7 @@ jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" } jtorctl = { module = "info.guardianproject:jtorctl", version.ref = "jtorctl" } junit = { group = "junit", name = "junit", version.ref = "junit" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" } lazysodium-android = { group = "com.goterl", name = "lazysodium-android", version.ref = "lazysodiumAndroid" } markdown-commonmark = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-commonmark", version.ref = "markdown" } diff --git a/quartz/build.gradle b/quartz/build.gradle index 12e9db7ed..0a6679022 100644 --- a/quartz/build.gradle +++ b/quartz/build.gradle @@ -47,7 +47,6 @@ kotlin { dependencies { implementation libs.androidx.core.ktx - implementation platform(libs.androidx.compose.bom) // @Immutable and @Stable diff --git a/quartz/notebooks/Kind1Test.ipynb b/quartz/notebooks/Kind1Test.ipynb new file mode 100644 index 000000000..707a981a8 --- /dev/null +++ b/quartz/notebooks/Kind1Test.ipynb @@ -0,0 +1,147 @@ +{ + "cells": [ + { + "metadata": {}, + "cell_type": "markdown", + "source": "## Creating a new Kind 1 post" + }, + { + "metadata": { + "jupyter": { + "is_executing": true + } + }, + "cell_type": "code", + "source": [ + "USE {\n", + " dependencies(\"fr.acinq.secp256k1:secp256k1-kmp-jni-jvm:0.17.3\")\n", + " dependencies(\"com.goterl:lazysodium-java:5.1.4\")\n", + " dependencies(\"net.java.dev.jna:jna:5.17.0\")\n", + "\n", + " import(\"com.vitorpamplona.quartz.*\")\n", + "}" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": { + "collapsed": true, + "ExecuteTime": { + "end_time": "2025-06-03T14:42:40.051027Z", + "start_time": "2025-06-03T14:42:40.023052Z" + } + }, + "source": [ + "import fr.acinq.secp256k1.Secp256k1\n", + "\n", + "import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair\n", + "import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal\n", + "import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync\n", + "import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent\n", + "\n", + "val signer = NostrSignerSync()\n", + "val kind1Template = TextNoteEvent.build(\"New kind 1 Post\")\n", + "val signedEvent = signer.sign(kind1Template)\n", + "\n", + "println(signedEvent)" + ], + "outputs": [ + { + "ename": "org.jetbrains.kotlinx.jupyter.exceptions.ReplCompilerException", + "evalue": "at Cell In[5], line 3, column 43: Unresolved reference: crypto\nat Cell In[5], line 4, column 43: Unresolved reference: signers\nat Cell In[5], line 5, column 43: Unresolved reference: signers\nat Cell In[5], line 6, column 33: Unresolved reference: nip10Notes\nat Cell In[5], line 8, column 14: Unresolved reference: NostrSignerSync\nat Cell In[5], line 9, column 21: Unresolved reference: TextNoteEvent\nat Cell In[5], line 12, column 1: Overload resolution ambiguity: \npublic inline fun println(message: Any?): Unit defined in kotlin.io\npublic inline fun println(message: Boolean): Unit defined in kotlin.io\npublic inline fun println(message: Byte): Unit defined in kotlin.io\npublic inline fun println(message: Char): Unit defined in kotlin.io\npublic inline fun println(message: CharArray): Unit defined in kotlin.io\npublic inline fun println(message: Double): Unit defined in kotlin.io\npublic inline fun println(message: Float): Unit defined in kotlin.io\npublic inline fun println(message: Int): Unit defined in kotlin.io\npublic inline fun println(message: Long): Unit defined in kotlin.io\npublic inline fun println(message: Short): Unit defined in kotlin.io", + "output_type": "error", + "traceback": [ + "org.jetbrains.kotlinx.jupyter.exceptions.ReplCompilerException: at Cell In[5], line 3, column 43: Unresolved reference: crypto", + "at Cell In[5], line 4, column 43: Unresolved reference: signers", + "at Cell In[5], line 5, column 43: Unresolved reference: signers", + "at Cell In[5], line 6, column 33: Unresolved reference: nip10Notes", + "at Cell In[5], line 8, column 14: Unresolved reference: NostrSignerSync", + "at Cell In[5], line 9, column 21: Unresolved reference: TextNoteEvent", + "at Cell In[5], line 12, column 1: Overload resolution ambiguity: ", + "public inline fun println(message: Any?): Unit defined in kotlin.io", + "public inline fun println(message: Boolean): Unit defined in kotlin.io", + "public inline fun println(message: Byte): Unit defined in kotlin.io", + "public inline fun println(message: Char): Unit defined in kotlin.io", + "public inline fun println(message: CharArray): Unit defined in kotlin.io", + "public inline fun println(message: Double): Unit defined in kotlin.io", + "public inline fun println(message: Float): Unit defined in kotlin.io", + "public inline fun println(message: Int): Unit defined in kotlin.io", + "public inline fun println(message: Long): Unit defined in kotlin.io", + "public inline fun println(message: Short): Unit defined in kotlin.io", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.JupyterCompilerImpl.compileSync(JupyterCompilerImpl.kt:208)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.InternalEvaluatorImpl.eval(InternalEvaluatorImpl.kt:126)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.CellExecutorImpl$execute$1$result$1.invoke(CellExecutorImpl.kt:80)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.CellExecutorImpl$execute$1$result$1.invoke(CellExecutorImpl.kt:78)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.withHost(ReplForJupyterImpl.kt:791)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.CellExecutorImpl.execute-L4Nmkdk(CellExecutorImpl.kt:78)", + "\tat org.jetbrains.kotlinx.jupyter.repl.execution.CellExecutor$DefaultImpls.execute-L4Nmkdk$default(CellExecutor.kt:13)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.evaluateUserCode-wNURfNM(ReplForJupyterImpl.kt:613)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.evalExImpl(ReplForJupyterImpl.kt:471)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.access$evalExImpl(ReplForJupyterImpl.kt:143)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl$evalEx$1.invoke(ReplForJupyterImpl.kt:464)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl$evalEx$1.invoke(ReplForJupyterImpl.kt:463)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.withEvalContext(ReplForJupyterImpl.kt:444)", + "\tat org.jetbrains.kotlinx.jupyter.repl.impl.ReplForJupyterImpl.evalEx(ReplForJupyterImpl.kt:463)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1$1.invoke(IdeCompatibleMessageRequestProcessor.kt:159)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1$1.invoke(IdeCompatibleMessageRequestProcessor.kt:158)", + "\tat org.jetbrains.kotlinx.jupyter.streams.BlockingSubstitutionEngine.withDataSubstitution(SubstitutionEngine.kt:70)", + "\tat org.jetbrains.kotlinx.jupyter.streams.StreamSubstitutionManager.withSubstitutedStreams(StreamSubstitutionManager.kt:118)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.withForkedIn(IdeCompatibleMessageRequestProcessor.kt:335)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.access$withForkedIn(IdeCompatibleMessageRequestProcessor.kt:54)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$evalWithIO$1$1.invoke(IdeCompatibleMessageRequestProcessor.kt:349)", + "\tat org.jetbrains.kotlinx.jupyter.streams.BlockingSubstitutionEngine.withDataSubstitution(SubstitutionEngine.kt:70)", + "\tat org.jetbrains.kotlinx.jupyter.streams.StreamSubstitutionManager.withSubstitutedStreams(StreamSubstitutionManager.kt:118)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.withForkedErr(IdeCompatibleMessageRequestProcessor.kt:324)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.access$withForkedErr(IdeCompatibleMessageRequestProcessor.kt:54)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$evalWithIO$1.invoke(IdeCompatibleMessageRequestProcessor.kt:348)", + "\tat org.jetbrains.kotlinx.jupyter.streams.BlockingSubstitutionEngine.withDataSubstitution(SubstitutionEngine.kt:70)", + "\tat org.jetbrains.kotlinx.jupyter.streams.StreamSubstitutionManager.withSubstitutedStreams(StreamSubstitutionManager.kt:118)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.withForkedOut(IdeCompatibleMessageRequestProcessor.kt:316)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor.evalWithIO(IdeCompatibleMessageRequestProcessor.kt:347)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1.invoke(IdeCompatibleMessageRequestProcessor.kt:158)", + "\tat org.jetbrains.kotlinx.jupyter.messaging.IdeCompatibleMessageRequestProcessor$processExecuteRequest$1$response$1.invoke(IdeCompatibleMessageRequestProcessor.kt:157)", + "\tat org.jetbrains.kotlinx.jupyter.execution.JupyterExecutorImpl$Task.execute(JupyterExecutorImpl.kt:41)", + "\tat org.jetbrains.kotlinx.jupyter.execution.JupyterExecutorImpl$executorThread$1.invoke(JupyterExecutorImpl.kt:83)", + "\tat org.jetbrains.kotlinx.jupyter.execution.JupyterExecutorImpl$executorThread$1.invoke(JupyterExecutorImpl.kt:80)", + "\tat kotlin.concurrent.ThreadsKt$thread$thread$1.run(Thread.kt:30)", + "" + ] + } + ], + "execution_count": 5 + }, + { + "metadata": {}, + "cell_type": "code", + "source": "", + "outputs": [], + "execution_count": null + } + ], + "metadata": { + "kernelspec": { + "display_name": "Kotlin", + "language": "kotlin", + "name": "kotlin" + }, + "language_info": { + "name": "kotlin", + "version": "1.9.23", + "mimetype": "text/x-kotlin", + "file_extension": ".kt", + "pygments_lexer": "kotlin", + "codemirror_mode": "text/x-kotlin", + "nbconvert_exporter": "" + }, + "ktnbPluginMetadata": { + "projectDependencies": [ + "Amethyst.amethyst.unitTest" + ], + "projectLibraries": false + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsTest.kt index 66c0d41d5..be92b41be 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip03Timestamp/ots/OtsTest.kt @@ -51,14 +51,14 @@ class OtsTest { @Test fun verifyNostrEvent2() { - val ots = Event.Companion.fromJson(otsEvent2) as OtsEvent + val ots = Event.fromJson(otsEvent2) as OtsEvent println(resolver.info(ots.otsByteArray())) assertEquals(1706322179L, ots.verify(resolver)) } @Test fun verifyNostrPendingEvent() { - val ots = Event.Companion.fromJson(otsPendingEvent) as OtsEvent + val ots = Event.fromJson(otsPendingEvent) as OtsEvent println(resolver.info(ots.otsByteArray())) assertEquals(null, ots.verify(resolver)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt index 17540a050..6fd03794e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.quartz -import com.vitorpamplona.quartz.blossom.BlossomAuthorizationEvent -import com.vitorpamplona.quartz.blossom.BlossomServersEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.edits.PrivateOutboxRelayListEvent @@ -82,6 +80,8 @@ import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent @@ -102,9 +102,9 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.CommunityListEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent @@ -119,6 +119,8 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent class EventFactory { companion object { @@ -205,12 +207,14 @@ class EventFactory { FhirResourceEvent.KIND -> FhirResourceEvent(id, pubKey, createdAt, tags, content, sig) FollowListEvent.KIND -> FollowListEvent(id, pubKey, createdAt, tags, content, sig) GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig) + GeohashListEvent.KIND -> GeohashListEvent(id, pubKey, createdAt, tags, content, sig) GiftWrapEvent.KIND -> GiftWrapEvent(id, pubKey, createdAt, tags, content, sig) GitIssueEvent.KIND -> GitIssueEvent(id, pubKey, createdAt, tags, content, sig) GitReplyEvent.KIND -> GitReplyEvent(id, pubKey, createdAt, tags, content, sig) GitPatchEvent.KIND -> GitPatchEvent(id, pubKey, createdAt, tags, content, sig) GitRepositoryEvent.KIND -> GitRepositoryEvent(id, pubKey, createdAt, tags, content, sig) GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig) + HashtagListEvent.KIND -> HashtagListEvent(id, pubKey, createdAt, tags, content, sig) HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) InteractiveStoryPrologueEvent.KIND -> InteractiveStoryPrologueEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/ParticipantTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/ParticipantTag.kt index eada7c645..3f7e65b26 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/ParticipantTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/audio/track/tags/ParticipantTag.kt @@ -24,6 +24,8 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -31,7 +33,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable data class ParticipantTag( override val pubKey: String, - override val relayHint: String?, + override val relayHint: NormalizedRelayUrl?, ) : PubKeyReferenceTag { fun toTagArray() = assemble(pubKey, relayHint) @@ -43,7 +45,7 @@ data class ParticipantTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ParticipantTag(tag[1], tag.getOrNull(2)) + return ParticipantTag(tag[1], tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }) } @JvmStatic @@ -57,7 +59,7 @@ data class ParticipantTag( @JvmStatic fun assemble( pubkey: HexKey, - relayHint: String? = null, - ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint) + relayHint: NormalizedRelayUrl? = null, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/PrivateOutboxRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/PrivateOutboxRelayListEvent.kt index 6b2e63d48..fe6ce61b9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/PrivateOutboxRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/PrivateOutboxRelayListEvent.kt @@ -23,10 +23,12 @@ package com.vitorpamplona.quartz.experimental.edits import android.util.Log import androidx.compose.runtime.Immutable import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.experimental.edits.tags.RelayTag import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @@ -53,22 +55,11 @@ class PrivateOutboxRelayListEvent( super.countMemory() + pointerSizeInBytes + (privateTagsCache?.sumOf { pointerSizeInBytes + it.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } } ?: 0) - fun relays(): List? = + fun relays(): List? = tags - .mapNotNull { - if (it.size > 1 && it[0] == "relay") { - it[1] - } else { - null - } - }.plus( - privateTagsCache?.mapNotNull { - if (it.size > 1 && it[0] == "relay") { - it[1] - } else { - null - } - } ?: emptyList(), + .mapNotNull(RelayTag::parse) + .plus( + privateTagsCache?.mapNotNull(RelayTag::parse) ?: emptyList(), ).ifEmpty { null } fun cachedPrivateTags(): Array>? = privateTagsCache @@ -125,25 +116,25 @@ class PrivateOutboxRelayListEvent( ) } - fun createTagArray(relays: List): Array> = + fun createTagArray(relays: List): Array> = relays .map { - arrayOf("relay", it) + RelayTag.assemble(it) }.toTypedArray() fun updateRelayList( earlierVersion: PrivateOutboxRelayListEvent, - relays: List, + relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (PrivateOutboxRelayListEvent) -> Unit, ) { val tags = earlierVersion.privateTagsCache - ?.filter { it[0] != "relay" } + ?.filter(RelayTag::notMatch) ?.plus( relays.map { - arrayOf("relay", it) + RelayTag.assemble(it) }, )?.toTypedArray() ?: emptyArray() @@ -156,7 +147,7 @@ class PrivateOutboxRelayListEvent( } fun createFromScratch( - relays: List, + relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (PrivateOutboxRelayListEvent) -> Unit, @@ -165,7 +156,7 @@ class PrivateOutboxRelayListEvent( } fun create( - relays: List, + relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (PrivateOutboxRelayListEvent) -> Unit, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/tags/RelayTag.kt new file mode 100644 index 000000000..c69057fd1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/edits/tags/RelayTag.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 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.quartz.experimental.edits.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +class RelayTag { + companion object { + const val TAG_NAME = "relay" + + @JvmStatic + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun notMatch(tag: Array) = tag.has(0) && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): NormalizedRelayUrl? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) ?: return null + + return relay + } + + @JvmStatic + fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/EphemeralChatEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/EphemeralChatEvent.kt index fff181bf4..593a2b556 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/EphemeralChatEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/EphemeralChatEvent.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.experimental.ephemChat.chat.tags.RoomTag import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @@ -39,11 +40,13 @@ class EphemeralChatEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun hasHomeRelay() = tags.any(RelayTag::match) + fun room() = tags.firstNotNullOfOrNull(RoomTag::parse) ?: DEFAULT_ROOM - fun relay() = tags.firstNotNullOfOrNull(RelayTag::parse) ?: "" + fun relay() = tags.firstNotNullOfOrNull(RelayTag::parse) - fun roomId() = RoomId(room(), relay()) + fun roomId() = relay()?.let { RoomId(room(), it) } companion object { const val KIND = 23333 @@ -53,7 +56,7 @@ class EphemeralChatEvent( fun build( message: String, - relay: String, + relay: NormalizedRelayUrl, room: String = DEFAULT_ROOM, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/RoomId.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/RoomId.kt index c515febfd..d249be351 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/RoomId.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/RoomId.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.quartz.experimental.ephemChat.chat -import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl data class RoomId( val id: String, - val relayUrl: String, + val relayUrl: NormalizedRelayUrl, ) { fun toKey() = "$id@$relayUrl" - fun toDisplayKey() = id + "@" + RelayUrlFormatter.displayUrl(relayUrl) + fun toDisplayKey() = id + "@" + relayUrl.displayUrl() } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/TagArrayBuilderExt.kt index 62c04bc09..1b5978f30 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/TagArrayBuilderExt.kt @@ -23,7 +23,8 @@ package com.vitorpamplona.quartz.experimental.ephemChat.chat import com.vitorpamplona.quartz.experimental.ephemChat.chat.tags.RelayTag import com.vitorpamplona.quartz.experimental.ephemChat.chat.tags.RoomTag import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun TagArrayBuilder.room(room: String) = addUnique(RoomTag.assemble(room)) -fun TagArrayBuilder.relay(room: String) = addUnique(RelayTag.assemble(room)) +fun TagArrayBuilder.relay(relay: NormalizedRelayUrl) = addUnique(RelayTag.assemble(relay)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RelayTag.kt index 65baca74a..4f596e31e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RelayTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RelayTag.kt @@ -20,7 +20,10 @@ */ package com.vitorpamplona.quartz.experimental.ephemChat.chat.tags +import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.ensure class RelayTag { @@ -28,14 +31,18 @@ class RelayTag { const val TAG_NAME = "relay" @JvmStatic - fun parse(tag: Array): String? { + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun parse(tag: Array): NormalizedRelayUrl? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } - return tag[1] + + return RelayUrlNormalizer.normalizeOrNull(tag[1]) ?: return null } @JvmStatic - fun assemble(url: String) = arrayOf(TAG_NAME, url) + fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RoomTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RoomTag.kt index 24fef9b5b..e97ea19d9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RoomTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/chat/tags/RoomTag.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.experimental.ephemChat.chat.tags +import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure @@ -27,6 +28,9 @@ class RoomTag { companion object { const val TAG_NAME = "d" + @JvmStatic + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + @JvmStatic fun parse(tag: Array): String? { ensure(tag.has(1)) { return null } @@ -36,6 +40,6 @@ class RoomTag { } @JvmStatic - fun assemble(url: String) = arrayOf(TAG_NAME, url) + fun assemble(room: String) = arrayOf(TAG_NAME, room) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoom.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoom.kt index 1d4674b7f..5d687851d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoom.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoom.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.LargeCache import kotlinx.coroutines.flow.MutableStateFlow @@ -31,7 +32,7 @@ import kotlinx.coroutines.flow.MutableStateFlow class Room( val roomId: RoomId, ) { - constructor(id: String, relayUrl: String) : this(RoomId(id, relayUrl)) + constructor(id: String, relayUrl: NormalizedRelayUrl) : this(RoomId(id, relayUrl)) val messages = LargeCache() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoomCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoomCache.kt index e2b7cb0dd..2785203e0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoomCache.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/db/EphemeralRoomCache.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.experimental.ephemChat.db import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.LargeCache class EphemeralRoomCache { @@ -28,12 +29,12 @@ class EphemeralRoomCache { fun getRoomIfExists( roomId: String, - relayUrl: String, + relayUrl: NormalizedRelayUrl, ): Room? = rooms.get(RoomId(roomId, relayUrl)) fun getOrCreateRoom( roomId: String, - relayUrl: String, + relayUrl: NormalizedRelayUrl, ): Room = rooms.getOrCreate(RoomId(roomId, relayUrl)) { Room(roomId, relayUrl) } fun findRoomsStartingWith(text: String): List { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt index d09b9c647..99ac56aa9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt @@ -81,9 +81,9 @@ class EphemeralChatListEvent( createdAt: Long = TimeUtils.now(), onReady: (EphemeralChatListEvent) -> Unit, ) { - val tags = arrayOf(RoomIdTag.Companion.assemble(room)) + val tags = arrayOf(RoomIdTag.assemble(room)) if (isPrivate) { - PrivateTagsInContent.Companion.encryptNip04( + PrivateTagsInContent.encryptNip04( privateTags = tags, signer = signer, ) { encryptedTags -> @@ -104,7 +104,7 @@ class EphemeralChatListEvent( ) { PrivateTagArrayBuilder.removeAll( earlierVersion, - RoomIdTag.Companion.assemble(room.id, room.relayUrl), + RoomIdTag.assemble(room.id, room.relayUrl), signer, ) { encryptedContent, newTags -> create(encryptedContent, newTags, signer, createdAt, onReady) @@ -121,7 +121,7 @@ class EphemeralChatListEvent( ) { PrivateTagArrayBuilder.add( earlierVersion, - RoomIdTag.Companion.assemble(room.id, room.relayUrl), + RoomIdTag.assemble(room.id, room.relayUrl.url), isPrivate, signer, ) { encryptedContent, newTags -> @@ -140,7 +140,7 @@ class EphemeralChatListEvent( if (tags.any { it.size > 1 && it[0] == "alt" }) { tags } else { - tags + AltTag.Companion.assemble(ALT) + tags + AltTag.assemble(ALT) } signer.sign(createdAt, KIND, newTags, content, onReady) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/tags/RoomIdTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/tags/RoomIdTag.kt index d8f569a13..382fd64d9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/tags/RoomIdTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/ephemChat/list/tags/RoomIdTag.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.experimental.ephemChat.list.tags import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.ensure class RoomIdTag { @@ -30,11 +32,13 @@ class RoomIdTag { @JvmStatic fun parse(tag: Array): RoomId? { - ensure(tag.has(1)) { return null } + ensure(tag.has(2)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } ensure(tag[2].isNotEmpty()) { return null } - return RoomId(tag[1], tag[2]) + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[2]) ?: return null + return RoomId(tag[1], relay) } @JvmStatic @@ -44,6 +48,12 @@ class RoomIdTag { ) = arrayOf(TAG_NAME, id, relayUrl) @JvmStatic - fun assemble(id: RoomId) = arrayOf(TAG_NAME, id.id, id.relayUrl) + fun assemble( + id: String, + relay: NormalizedRelayUrl, + ) = assemble(id, relay.url) + + @JvmStatic + fun assemble(id: RoomId) = arrayOf(TAG_NAME, id.id, id.relayUrl.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/MarkedETagExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/MarkedETagExt.kt index 863a021b2..cc2971896 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/MarkedETagExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/forks/MarkedETagExt.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.experimental.forks +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag.MARKER @@ -29,8 +30,8 @@ fun MarkedETag.Companion.parseFork(tag: Array): MarkedETag? { // ["e", id hex, relay hint, marker, pubkey] return MarkedETag( tag[ORDER_EVT_ID], - tag[ORDER_RELAY], - tag[ORDER_MARKER], + tag[ORDER_RELAY].ifBlank { null }?.let { RelayUrlNormalizer.normalizeOrNull(it) }, + MARKER.FORK, tag.getOrNull( ORDER_PUBKEY, ), diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt index 83ac71bb4..1d5725f0f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.builder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag @@ -83,7 +84,7 @@ class InteractiveStoryReadingStateEvent( fun update( base: InteractiveStoryReadingStateEvent, currentScene: InteractiveStoryBaseEvent, - currentSceneRelay: String?, + currentSceneRelay: NormalizedRelayUrl?, createdAt: Long = TimeUtils.now(), ): EventTemplate { val rootTag = base.dTag() @@ -109,9 +110,9 @@ class InteractiveStoryReadingStateEvent( fun build( root: InteractiveStoryBaseEvent, - rootRelay: String?, + rootRelay: NormalizedRelayUrl?, currentScene: InteractiveStoryBaseEvent, - currentSceneRelay: String?, + currentSceneRelay: NormalizedRelayUrl?, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/RootSceneTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/RootSceneTag.kt index 0a1054f5d..ab50b9071 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/RootSceneTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/interactiveStories/tags/RootSceneTag.kt @@ -23,12 +23,13 @@ package com.vitorpamplona.quartz.experimental.interactiveStories.tags import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.ensure import com.vitorpamplona.quartz.utils.pointerSizeInBytes -import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers @Immutable data class RootSceneTag( @@ -36,13 +37,13 @@ data class RootSceneTag( val pubKeyHex: String, val dTag: String, ) { - var relay: String? = null + var relay: NormalizedRelayUrl? = null constructor( kind: Int, pubKeyHex: HexKey, dTag: String, - relayHint: String?, + relayHint: NormalizedRelayUrl?, ) : this(kind, pubKeyHex, dTag) { this.relay = relayHint } @@ -52,11 +53,11 @@ data class RootSceneTag( 8L + // kind pubKeyHex.bytesUsedInMemory() + dTag.bytesUsedInMemory() + - (relay?.bytesUsedInMemory() ?: 0) + (relay?.url?.bytesUsedInMemory() ?: 0) fun toTag() = assembleATagId(kind, pubKeyHex, dTag) - fun toTagArray() = removeTrailingNullsAndEmptyOthers(TAG_NAME, toTag(), relay) + fun toTagArray() = assemble(kind, pubKeyHex, dTag, relay) companion object { const val TAG_NAME = "A" @@ -73,21 +74,22 @@ data class RootSceneTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } val address = Address.parse(tag[1]) ?: return null - return RootSceneTag(address.kind, address.pubKeyHex, address.dTag, tag.getOrNull(2)) + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + return RootSceneTag(address.kind, address.pubKeyHex, address.dTag, hint) } @JvmStatic fun assemble( aTagId: HexKey, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, aTagId, relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url) @JvmStatic fun assemble( kind: Int, pubKeyHex: String, dTag: String, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, assembleATagId(kind, pubKeyHex, dTag), relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, assembleATagId(kind, pubKeyHex, dTag), relay?.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/medical/FhirResourceEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/medical/FhirResourceEvent.kt index f9082bd77..57577ee37 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/medical/FhirResourceEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/medical/FhirResourceEvent.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.quartz.experimental.medical import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent.Companion.ALT import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -47,7 +46,7 @@ class FhirResourceEvent( createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, fhirPayload, createdAt) { - alt(ALT) + alt(ALT_DESCRIPTION) initializer() } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt index 7ea3a92bb..56bcfc9ae 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.experimental.profileGallery import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -68,5 +69,5 @@ fun TagArrayBuilder.service(service: String) = add(Ser fun TagArrayBuilder.fromEvent( event: HexKey, - relayHint: String?, + relayHint: NormalizedRelayUrl?, ) = add(ETag.assemble(event, relayHint, null)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt index 914b90bb3..c59f5b019 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @@ -28,7 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address interface AddressableEvent : IEvent { fun dTag(): String - fun aTag(relayHint: String? = null): ATag + fun aTag(relayHint: NormalizedRelayUrl? = null): ATag fun address(): Address diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt index b2727bb2e..cf857f66c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag @@ -38,7 +39,7 @@ open class BaseAddressableEvent( AddressableEvent { override fun dTag() = tags.dTag() - override fun aTag(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) + override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint) override fun address() = Address(kind, pubKey, dTag()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt index c8632b9a5..8f520077b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @@ -36,7 +37,7 @@ open class BaseReplaceableEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { override fun dTag() = FIXED_D_TAG - override fun aTag(relayHint: String?) = ATag(kind, pubKey, FIXED_D_TAG, relayHint) + override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, FIXED_D_TAG, relayHint) override fun address() = Address(kind, pubKey, dTag()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/EventHintBundle.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/EventHintBundle.kt index c5bbd6e1a..56961814c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/EventHintBundle.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/EventHintBundle.kt @@ -22,11 +22,13 @@ package com.vitorpamplona.quartz.nip01Core.hints import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes @@ -35,10 +37,10 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes data class EventHintBundle( val event: T, ) { - var relay: String? = null - var authorHomeRelay: String? = null + var relay: NormalizedRelayUrl? = null + var authorHomeRelay: NormalizedRelayUrl? = null - constructor(event: T, relayHint: String? = null, authorHomeRelay: String? = null) : this(event) { + constructor(event: T, relayHint: NormalizedRelayUrl? = null, authorHomeRelay: NormalizedRelayUrl? = null) : this(event) { this.relay = relayHint this.authorHomeRelay = authorHomeRelay } @@ -46,7 +48,7 @@ data class EventHintBundle( fun countMemory(): Long = 2 * pointerSizeInBytes + // 2 fields, 4 bytes each reference (32bit) event.countMemory() + - (relay?.bytesUsedInMemory() ?: 0) + (relay?.url?.bytesUsedInMemory() ?: 0) fun toNEvent(): String = NEvent.create(event.id, event.pubKey, event.kind, relay) @@ -60,5 +62,5 @@ data class EventHintBundle( fun toETagArray() = ETag.assemble(event.id, relay, event.pubKey) - fun toQTagArray() = ETag(event.id, relay, event.pubKey).toQTagArray() + fun toQTagArray() = QEventTag.assemble(event.id, relay, event.pubKey) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexer.kt index 351de15ff..d6d498525 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexer.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip01Core.hints import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.hints.bloom.BloomFilterMurMur3 +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.LargeCache /** * Instead of having one bloom filter per relay per type, which could create @@ -34,70 +36,70 @@ class HintIndexer { private val eventHints = BloomFilterMurMur3(10_000_000, 5) private val addressHints = BloomFilterMurMur3(2_000_000, 5) private val pubKeyHints = BloomFilterMurMur3(10_000_000, 5) - private val relayDB = mutableSetOf() + private val relayDB = LargeCache() private fun add( id: ByteArray, - relay: String, + relay: NormalizedRelayUrl, bloom: BloomFilterMurMur3, ) { - relayDB.add(relay) + relayDB.put(relay, relay) bloom.add(id, relay.hashCode()) } - private fun get( + private fun getHintsFor( id: ByteArray, bloom: BloomFilterMurMur3, - ) = relayDB.filter { bloom.mightContain(id, it.hashCode()) } + ) = relayDB.filter { relay, _ -> bloom.mightContain(id, relay.hashCode()) } // -------------------- // Event Host hints // -------------------- fun addEvent( eventId: ByteArray, - relay: String, + relay: NormalizedRelayUrl, ) = add(eventId, relay, eventHints) fun addEvent( eventId: HexKey, - relay: String, + relay: NormalizedRelayUrl, ) = addEvent(eventId.hexToByteArray(), relay) - fun getEvent(eventId: ByteArray) = get(eventId, eventHints) + fun hintsForEvent(eventId: ByteArray) = getHintsFor(eventId, eventHints) - fun getEvent(eventId: HexKey) = getEvent(eventId.hexToByteArray()) + fun hintsForEvent(eventId: HexKey) = hintsForEvent(eventId.hexToByteArray()) // -------------------- // PubKeys Outbox hints // -------------------- fun addAddress( addressId: ByteArray, - relay: String, + relay: NormalizedRelayUrl, ) = add(addressId, relay, addressHints) fun addAddress( addressId: String, - relay: String, + relay: NormalizedRelayUrl, ) = addAddress(addressId.toByteArray(), relay) - fun getAddress(addressId: ByteArray) = get(addressId, addressHints) + fun hintsForAddress(addressId: ByteArray) = getHintsFor(addressId, addressHints) - fun getAddress(addressId: String) = getAddress(addressId.toByteArray()) + fun hintsForAddress(addressId: String) = hintsForAddress(addressId.toByteArray()) // -------------------- // PubKeys Outbox hints // -------------------- fun addKey( key: ByteArray, - relay: String, + relay: NormalizedRelayUrl, ) = add(key, relay, pubKeyHints) fun addKey( key: HexKey, - relay: String, + relay: NormalizedRelayUrl, ) = addKey(key.hexToByteArray(), relay) - fun getKey(key: ByteArray) = get(key, pubKeyHints) + fun hintsForKey(key: ByteArray) = getHintsFor(key, pubKeyHints) - fun getKey(key: HexKey) = getKey(key.hexToByteArray()) + fun hintsForKey(key: HexKey) = hintsForKey(key.hexToByteArray()) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/AddressHint.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/AddressHint.kt index 26c8f35c5..9292790a5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/AddressHint.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/AddressHint.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.quartz.nip01Core.hints.types +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + data class AddressHint( val addressId: String, - var relay: String? = null, + val relay: NormalizedRelayUrl, ) : Hint { override fun id() = addressId.toByteArray(Charsets.UTF_8) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/EventIdHint.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/EventIdHint.kt index 88015fe0f..556cb221b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/EventIdHint.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/EventIdHint.kt @@ -22,10 +22,18 @@ package com.vitorpamplona.quartz.nip01Core.hints.types import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl data class EventIdHint( val eventId: HexKey, - var relay: String? = null, + val relay: NormalizedRelayUrl, +) : Hint { + override fun id() = eventId.hexToByteArray() +} + +data class EventIdHintOptional( + val eventId: HexKey, + val relay: NormalizedRelayUrl? = null, ) : Hint { override fun id() = eventId.hexToByteArray() } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/PubKeyHint.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/PubKeyHint.kt index a8040bb51..f514f68a4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/PubKeyHint.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/hints/types/PubKeyHint.kt @@ -22,10 +22,11 @@ package com.vitorpamplona.quartz.nip01Core.hints.types import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl data class PubKeyHint( val pubkey: HexKey, - var relay: String? = null, + val relay: NormalizedRelayUrl, ) : Hint { override fun id() = pubkey.hexToByteArray() } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt new file mode 100644 index 000000000..ce1eeca33 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -0,0 +1,276 @@ +/** + * Copyright (c) 2025 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.quartz.nip01Core.relay.client + +import android.util.Log +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolEventOutboxRepository +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolSubscriptionRepository +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayPool +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.forEach +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +/** + * The Nostr Client manages a relay pool, keeps active subscriptions and manages sending of events. + */ +class NostrClient( + private val websocketBuilder: WebsocketBuilder, + private val scope: CoroutineScope, +) : IRelayClientListener { + private val relayPool: RelayPool = RelayPool(this, ::buildRelay) + private val activeSubscriptions: PoolSubscriptionRepository = PoolSubscriptionRepository() + private val eventOutbox: PoolEventOutboxRepository = PoolEventOutboxRepository() + + private var listeners = setOf() + + /** + * Whatches for any changes in the relay list from subscriptions or outbox + * and updates the relayPool as needed. + */ + private val allRelays = + combine( + activeSubscriptions.relays, + eventOutbox.relays, + ) { subs, outbox -> + subs + outbox + }.onStart { + activeSubscriptions.relays.value + eventOutbox.relays.value + }.onEach { + relayPool.updatePool(it) + + val subs = activeSubscriptions.allSubscriptions() + + it.forEach { relay -> + var hasSub = false + subs.forEach { sub -> + sub.value.filters.forEach { + if (it.isValidFor(relay)) { + hasSub = true + } + } + } + if (!hasSub) { + Log.d("NostrClient", "AABBCC $relay doesn't have any sub") + } + + if (relayPool.getRelay(relay)?.isConnected() == false) { + Log.d("NostrClient", "AABBCC $relay is not connected") + } + } + + val filters = subs.values.sumOf { it.filters.size } + Log.d("NostrClient", "Updating list for relays to ${it.size} relays and ${subs.size} subscriptions and $filters filters") + it.forEach { + Log.d("NostrClient", "${it.url} isConnected ${relayPool.getRelay(it)?.isConnected()}") + } + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Companion.Eagerly, + activeSubscriptions.relays.value + eventOutbox.relays.value, + ) + + fun buildRelay(relay: NormalizedRelayUrl): IRelayClient = + BasicRelayClient( + url = relay, + socketBuilder = websocketBuilder, + listener = relayPool, + stats = RelayStats.get(relay), + ) { liveRelay -> + activeSubscriptions.forEachSub(relay, liveRelay::sendRequest) + eventOutbox.forEachUnsentEvent(relay, liveRelay::send) + } + + fun connectedRelayList() = relayPool.getAll() + + // Reconnects all relays that may have disconnected + fun connect() = relayPool.connect() + + // Reconnects all relays that may have disconnected + fun disconnect() = relayPool.disconnect() + + @Synchronized + fun reconnect(onlyIfChanged: Boolean = false) { + if (onlyIfChanged) { + relayPool.getAllNeedsToReconnect().forEach { + it.disconnect() + } + relayPool.connect() + } else { + relayPool.disconnect() + relayPool.connect() + } + } + + fun sendFilter( + subscriptionId: String = newSubId(), + filters: List = listOf(), + ) { + filters.forEach { + Log.d("NostrClient", "${it.relay.url} ${it.filter.toJson()}") + } + + activeSubscriptions.addOrUpdate(subscriptionId, filters) + relayPool.sendRequest(subscriptionId, filters) + } + + fun sendFilterOnlyIfDisconnected( + subscriptionId: String = newSubId(), + filters: List = listOf(), + ) { + activeSubscriptions.addOrUpdate(subscriptionId, filters) + relayPool.connectIfDisconnected() + } + + fun sendIfExists( + signedEvent: Event, + connectedRelay: NormalizedRelayUrl, + ) { + relayPool.getRelay(connectedRelay)?.send(signedEvent) + } + + fun send( + signedEvent: Event, + relayList: Set, + ) { + eventOutbox.markAsSending(signedEvent, relayList) + relayPool.send(signedEvent, relayList) + } + + fun close(subscriptionId: String) { + relayPool.close(subscriptionId) + activeSubscriptions.remove(subscriptionId) + } + + fun isActive(subscriptionId: String): Boolean = activeSubscriptions.isActive(subscriptionId) + + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + listeners.forEach { it.onEvent(relay, subId, event, arrivalTime, afterEOSE) } + } + + override fun onEOSE( + relay: IRelayClient, + subId: String, + arrivalTime: Long, + ) { + listeners.forEach { it.onEOSE(relay, subId, arrivalTime) } + } + + override fun onRelayStateChange( + relay: IRelayClient, + type: RelayState, + ) { + listeners.forEach { it.onRelayStateChange(relay, type) } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onBeforeSend( + relay: IRelayClient, + event: Event, + ) { + eventOutbox.newTry(event.id, relay.url) + listeners.forEach { it.onBeforeSend(relay, event) } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onSendResponse( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) { + eventOutbox.newResponse(eventId, relay.url, success, message) + listeners.forEach { it.onSendResponse(relay, eventId, success, message) } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onAuth( + relay: IRelayClient, + challenge: String, + ) { + listeners.forEach { it.onAuth(relay, challenge) } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onNotify( + relay: IRelayClient, + description: String, + ) { + listeners.forEach { it.onNotify(relay, description) } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onSend( + relay: IRelayClient, + msg: String, + success: Boolean, + ) { + listeners.forEach { it.onSend(relay, msg, success) } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onError( + relay: IRelayClient, + subId: String, + error: Error, + ) { + listeners.forEach { it.onError(relay, subId, error) } + } + + fun subscribe(listener: IRelayClientListener) { + listeners = listeners.plus(listener) + } + + fun isSubscribed(listener: IRelayClientListener): Boolean = listeners.contains(listener) + + fun unsubscribe(listener: IRelayClientListener) { + listeners = listeners.minus(listener) + } + + fun getSubscriptionFiltersOrNull(subId: String): List? = activeSubscriptions.getSubscriptionFiltersOrNull(subId) + + fun connectedRelays() = relayPool.connectedRelays() + + fun relayStatusFlow() = relayPool.statusFlow +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/EventCollector.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories/EventCollector.kt similarity index 80% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/EventCollector.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories/EventCollector.kt index 156873ac5..42d63f53a 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/EventCollector.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories/EventCollector.kt @@ -18,26 +18,27 @@ * 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 +package com.vitorpamplona.quartz.nip01Core.relay.client.acessories import android.util.Log -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.Relay import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient /** * Listens to NostrClient's onEvent messages for caching purposes. */ class EventCollector( val client: NostrClient, - val onEvent: (event: Event, relay: Relay) -> Unit, + val onEvent: (event: Event, relay: IRelayClient) -> Unit, ) { private val clientListener = - object : NostrClient.Listener { + object : IRelayClientListener { override fun onEvent( + relay: IRelayClient, + subId: String, event: Event, - subscriptionId: String, - relay: Relay, arrivalTime: Long, afterEOSE: Boolean, ) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories/NostrClientSingleDownloadExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories/NostrClientSingleDownloadExt.kt new file mode 100644 index 000000000..cd2645eed --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories/NostrClientSingleDownloadExt.kt @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2025 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.quartz.nip01Core.relay.client.acessories + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId + +fun NostrClient.downloadFirstEvent( + subscriptionId: String = newSubId(), + filters: List = listOf(), + onResponse: (Event) -> Unit, +) { + subscribe( + object : IRelayClientListener { + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + if (subId == subscriptionId) { + unsubscribe(this) + close(subscriptionId) + + onResponse(event) + } + } + + override fun onClosed( + relay: IRelayClient, + subId: String, + message: String, + ) { + unsubscribe(this) + close(subscriptionId) + super.onClosed(relay, subId, message) + } + + override fun onEOSE( + relay: IRelayClient, + subId: String, + arrivalTime: Long, + ) { + unsubscribe(this) + close(subscriptionId) + super.onEOSE(relay, subId, arrivalTime) + } + + override fun onError( + relay: IRelayClient, + subId: String, + error: Error, + ) { + unsubscribe(this) + close(subscriptionId) + super.onError(relay, subId, error) + } + + override fun onRelayStateChange( + relay: IRelayClient, + type: RelayState, + ) { + if (type == RelayState.DISCONNECTED) { + unsubscribe(this) + close(subscriptionId) + } + super.onRelayStateChange(relay, type) + } + }, + ) + + sendFilter(subscriptionId, filters) +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayAuthenticator.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories/RelayAuthenticator.kt similarity index 79% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayAuthenticator.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories/RelayAuthenticator.kt index 93b86c0d8..9e60606e9 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayAuthenticator.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories/RelayAuthenticator.kt @@ -18,20 +18,21 @@ * 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 +package com.vitorpamplona.quartz.nip01Core.relay.client.acessories import android.util.Log -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.Relay +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient class RelayAuthenticator( val client: NostrClient, - val authenticate: (challenge: String, relay: Relay) -> Unit, + val authenticate: (challenge: String, relay: IRelayClient) -> Unit, ) { private val clientListener = - object : NostrClient.Listener { + object : IRelayClientListener { override fun onAuth( - relay: Relay, + relay: IRelayClient, challenge: String, ) { authenticate(challenge, relay) diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayInsertConfirmationCollector.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories/RelayInsertConfirmationCollector.kt similarity index 81% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayInsertConfirmationCollector.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories/RelayInsertConfirmationCollector.kt index d4b7b3cf6..6757dfa76 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayInsertConfirmationCollector.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories/RelayInsertConfirmationCollector.kt @@ -18,27 +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.datasources +package com.vitorpamplona.quartz.nip01Core.relay.client.acessories import android.util.Log -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.Relay import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient /** * Listens to NostrClient's onEvent messages for caching purposes. */ class RelayInsertConfirmationCollector( val client: NostrClient, - val onRelayReceived: (eventId: HexKey, relay: Relay) -> Unit, + val onRelayReceived: (eventId: HexKey, relay: IRelayClient) -> Unit, ) { private val clientListener = - object : NostrClient.Listener { + object : IRelayClientListener { override fun onSendResponse( + relay: IRelayClient, eventId: String, success: Boolean, message: String, - relay: Relay, ) { if (success) { onRelayReceived(eventId, relay) diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayLogger.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories/RelayLogger.kt similarity index 79% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayLogger.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories/RelayLogger.kt index bc6721e48..d1d51a3b7 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayLogger.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories/RelayLogger.kt @@ -18,35 +18,36 @@ * 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 +package com.vitorpamplona.quartz.nip01Core.relay.client.acessories import android.util.Log -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.Relay import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient /** * Listens to NostrClient's onNotify messages from the relay */ class RelayLogger( val client: NostrClient, - val notify: (message: String, relay: Relay) -> Unit, + val notify: (message: String, relay: IRelayClient) -> Unit, ) { private val clientListener = - object : NostrClient.Listener { + object : IRelayClientListener { /** A new message was received */ override fun onEvent( + relay: IRelayClient, + subId: String, event: Event, - subscriptionId: String, - relay: Relay, arrivalTime: Long, afterEOSE: Boolean, ) { - Log.d("Relay", "Relay onEVENT ${relay.url} ($subscriptionId - $afterEOSE) ${event.toJson()}") + Log.d("Relay", "Relay onEVENT ${relay.url} ($subId - $afterEOSE) ${event.toJson()}") } override fun onSend( - relay: Relay, + relay: IRelayClient, msg: String, success: Boolean, ) { diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayNotifier.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories/RelayNotifier.kt similarity index 80% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayNotifier.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories/RelayNotifier.kt index 178a6b676..3d3a33fb6 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/datasources/RelayNotifier.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/acessories/RelayNotifier.kt @@ -18,27 +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.datasources +package com.vitorpamplona.quartz.nip01Core.relay.client.acessories import android.util.Log -import com.vitorpamplona.ammolite.relays.NostrClient -import com.vitorpamplona.ammolite.relays.Relay +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient /** * Listens to NostrClient's onNotify messages from the relay */ class RelayNotifier( val client: NostrClient, - val notify: (message: String, relay: Relay) -> Unit, + val notify: (message: String, relay: IRelayClient) -> Unit, ) { companion object { val TAG = RelayNotifier::class.java.simpleName } private val clientListener = - object : NostrClient.Listener { + object : IRelayClientListener { override fun onNotify( - relay: Relay, + relay: IRelayClient, message: String, ) { notify(message, relay) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt new file mode 100644 index 000000000..364776fe5 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt @@ -0,0 +1,138 @@ +/** + * Copyright (c) 2025 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.quartz.nip01Core.relay.client.listeners + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient + +enum class RelayState { + // Websocket connected + CONNECTED, + + // Websocket disconnecting + DISCONNECTING, + + // Websocket disconnected + DISCONNECTED, +} + +interface IRelayClientListener { + /** + * New Event arrives from the relay. + */ + fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) {} + + /** + * New EOSE command arrives for a subscription + */ + fun onEOSE( + relay: IRelayClient, + subId: String, + arrivalTime: Long, + ) {} + + /** + * New error + */ + fun onError( + relay: IRelayClient, + subId: String, + error: Error, + ) {} + + /** + * Relay is requesting authentication with the given challenge. + */ + fun onAuth( + relay: IRelayClient, + challenge: String, + ) {} + + /** + * called after the relay receives the OK from an Auth message + */ + fun onAuthed( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) {} + + /** + * RelayState changes + */ + fun onRelayStateChange( + relay: IRelayClient, + type: RelayState, + ) {} + + /** + * NOTIFY command has arrived. + */ + fun onNotify( + relay: IRelayClient, + description: String, + ) {} + + /** + * Relay closed the subscription + */ + fun onClosed( + relay: IRelayClient, + subId: String, + message: String, + ) {} + + /** + * Triggers this before sending the event. + */ + fun onBeforeSend( + relay: IRelayClient, + event: Event, + ) {} + + /** + * Triggers after the event has been sent. + */ + fun onSend( + relay: IRelayClient, + msg: String, + success: Boolean, + ) {} + + /** + * Relay accepted or rejected the event + */ + fun onSendResponse( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) {} +} + +object EmptyClientListener : IRelayClientListener diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt new file mode 100644 index 000000000..75c14b13c --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2025 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.quartz.nip01Core.relay.client.listeners + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient + +open class RedirectRelayClientListener( + val listener: IRelayClientListener, +) : IRelayClientListener { + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) = listener.onEvent(relay, subId, event, arrivalTime, afterEOSE) + + override fun onEOSE( + relay: IRelayClient, + subId: String, + arrivalTime: Long, + ) = listener.onEOSE(relay, subId, arrivalTime) + + override fun onError( + relay: IRelayClient, + subId: String, + error: Error, + ) = listener.onError(relay, subId, error) + + override fun onAuth( + relay: IRelayClient, + challenge: String, + ) = listener.onAuth(relay, challenge) + + override fun onAuthed( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) = listener.onAuthed(relay, eventId, success, message) + + override fun onRelayStateChange( + relay: IRelayClient, + type: RelayState, + ) = listener.onRelayStateChange(relay, type) + + override fun onNotify( + relay: IRelayClient, + description: String, + ) = listener.onNotify(relay, description) + + override fun onClosed( + relay: IRelayClient, + subId: String, + message: String, + ) = listener.onClosed(relay, subId, message) + + override fun onBeforeSend( + relay: IRelayClient, + event: Event, + ) = listener.onBeforeSend(relay, event) + + override fun onSend( + relay: IRelayClient, + msg: String, + success: Boolean, + ) = listener.onSend(relay, msg, success) + + override fun onSendResponse( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) = listener.onSendResponse(relay, eventId, success, message) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt new file mode 100644 index 000000000..1cda88e1d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2025 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.quartz.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.any + +class PoolEventOutbox( + val event: Event, + var relays: Set, +) { + private val tries = mutableMapOf() + + fun updateRelays(newRelays: Set) { + relays = newRelays + } + + fun isDone(url: NormalizedRelayUrl) = tries[url]?.let { it.isDone() } ?: false + + fun isDone() = relays.all { isDone(it) } + + fun relaysLeft(): Set = relays.filterTo(mutableSetOf()) { !isDone(it) } + + fun isSupposedToGo(url: NormalizedRelayUrl) = url in relays && !isDone(url) + + fun forEachUnsentEvent( + url: NormalizedRelayUrl, + run: (url: Event) -> Unit, + ) = if (isSupposedToGo(url)) run(event) else null + + fun newTry(url: NormalizedRelayUrl) { + val currentTries = tries[url] + if (currentTries != null) { + currentTries.tries.add(TimeUtils.now()) + } else { + tries.put(url, Tries(mutableListOf(TimeUtils.now()))) + } + } + + fun newResponse( + url: NormalizedRelayUrl, + success: Boolean, + message: String, + ) { + val currentTries = tries[url] + if (currentTries != null) { + currentTries.responses.add(Response(success, message)) + } else { + tries.put( + url, + Tries( + mutableListOf(TimeUtils.now() - 1), + mutableListOf(Response(success, message)), + ), + ) + } + } + + // Tries 3 times + class Tries( + val tries: MutableList = mutableListOf(), + val responses: MutableList = mutableListOf(), + ) { + fun isDone() = responses.any { it.success == true } || responses.size > 2 || tries.size > 3 + } + + class Response( + val success: Boolean, + val message: String, + ) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt new file mode 100644 index 000000000..b85419258 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2025 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.quartz.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.MutableStateFlow + +class PoolEventOutboxRepository { + private var eventOutbox = mapOf() + val relays = MutableStateFlow(setOf()) + + fun updateRelays() { + val myRelays = mutableSetOf() + eventOutbox.values.forEach { + myRelays.addAll(it.relaysLeft()) + } + + if (relays.value != myRelays) { + relays.tryEmit(myRelays) + } + } + + fun markAsSending( + event: Event, + relays: Set, + ) { + val currentOutbox = eventOutbox[event.id] + if (currentOutbox == null) { + eventOutbox = eventOutbox + Pair(event.id, PoolEventOutbox(event, relays)) + } else { + currentOutbox.updateRelays(relays) + } + updateRelays() + } + + fun newTry( + id: HexKey, + url: NormalizedRelayUrl, + ) { + val waiting = eventOutbox[id] + if (waiting != null) { + waiting.newTry(url) + } + } + + fun newResponse( + id: HexKey, + url: NormalizedRelayUrl, + success: Boolean, + message: String, + ) { + val waiting = eventOutbox[id] + if (waiting != null) { + waiting.newResponse(url, success, message) + clear() + } + } + + fun clear() { + eventOutbox = eventOutbox.filter { !it.value.isDone() } + updateRelays() + } + + fun forEachUnsentEvent( + url: NormalizedRelayUrl, + run: (url: Event) -> Unit, + ) { + eventOutbox.forEach { + it.value.forEachUnsentEvent(url, run) + } + } +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/SubscriptionCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscription.kt similarity index 77% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/SubscriptionCache.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscription.kt index 00f3f1e15..1f48aa7cc 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/SubscriptionCache.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscription.kt @@ -18,14 +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.ammolite.relays +package com.vitorpamplona.quartz.nip01Core.relay.client.pool -interface SubscriptionCache { - fun isActive(subscriptionId: String): Boolean +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl - fun allSubscriptions(): Map> - - fun getSubscriptionFilters(subId: String): List - - fun getSubscriptionFiltersOrNull(subId: String): List? +class PoolSubscription( + var filters: List = emptyList(), +) { + fun toFilter(relay: NormalizedRelayUrl) = filters.mapNotNull { it.toFilter(relay) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt new file mode 100644 index 000000000..19a0aa404 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2025 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.quartz.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.MutableStateFlow + +class PoolSubscriptionRepository { + private var subscriptions = mapOf() + val relays = MutableStateFlow(setOf()) + + fun updateRelays() { + val myRelays = mutableSetOf() + subscriptions.values.forEach { + it.filters.forEach { + if (!myRelays.contains(it.relay)) { + myRelays.add(it.relay) + } + } + } + + if (relays.value != myRelays) { + relays.tryEmit(myRelays) + } + } + + fun addOrUpdate( + subscriptionId: String, + filters: List = listOf(), + ) { + val currentFilter = subscriptions[subscriptionId] + if (currentFilter == null) { + subscriptions = subscriptions + Pair(subscriptionId, PoolSubscription(filters)) + } else { + currentFilter.filters = filters + } + updateRelays() + } + + fun remove(subscriptionId: String) { + if (subscriptions.contains(subscriptionId)) { + subscriptions = subscriptions.minus(subscriptionId) + updateRelays() + } + } + + fun forEachSub( + relay: NormalizedRelayUrl, + run: (String, List) -> Unit, + ) { + subscriptions.forEach { (subId, filters) -> + val filters = filters.toFilter(relay) + if (filters.isNotEmpty()) { + run(subId, filters) + } else { + null + } + } + } + + fun isActive(subscriptionId: String): Boolean = subscriptions.contains(subscriptionId) + + fun allSubscriptions(): Map = subscriptions + + fun getSubscriptionFilters(subId: String): List = subscriptions[subId]?.filters ?: emptyList() + + fun getSubscriptionFiltersOrNull(subId: String): List? = subscriptions[subId]?.filters +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt new file mode 100644 index 000000000..d4922d889 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2025 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.quartz.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Represents a filter that should only be sent to a given relay. + */ +class RelayBasedFilter( + val relay: NormalizedRelayUrl, + val filter: Filter, +) { + // This only exists because some relays confuse empty lists with null lists + fun isValidFor(relay: NormalizedRelayUrl) = relay == this.relay + + fun toFilter(relay: NormalizedRelayUrl): Filter? { + return if (isValidFor(relay)) { + filter + } else { + null + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt new file mode 100644 index 000000000..484fe760b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt @@ -0,0 +1,299 @@ +/** + * Copyright (c) 2025 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.quartz.nip01Core.relay.client.pool + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.LargeCache +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlin.collections.forEach +import kotlin.collections.isNotEmpty +import kotlin.collections.mapNotNull + +val UnsupportedRelayCreation: (url: NormalizedRelayUrl) -> IRelayClient = { + throw UnsupportedOperationException("Cannot create new relays") +} + +/** + * RelayPool manages the connection to multiple Relays and lets consumers deal with simple events. + */ +class RelayPool( + val listener: IRelayClientListener = EmptyClientListener, + val createNewRelay: (url: NormalizedRelayUrl) -> IRelayClient = UnsupportedRelayCreation, +) : IRelayClientListener { + private val relays = LargeCache() + + // Backing property to avoid flow emissions from other classes + private val _statusFlow = MutableStateFlow(RelayPoolStatus()) + val statusFlow: StateFlow = _statusFlow.asStateFlow() + + fun getRelay(url: NormalizedRelayUrl): IRelayClient? = relays.get(url) + + fun getAll() = relays.keys() + + fun getAllNeedsToReconnect() = relays.filter { url, relay -> relay.needsToReconnect() } + + fun reconnectsRelaysThatNeedTo() { + relays.forEach { url, relay -> + if (relay.needsToReconnect()) { + relay.disconnect() + relay.connect() + } + } + } + + fun connect() = + relays.forEach { url, relay -> + relay.connect() + } + + fun connectIfDisconnected() = + relays.forEach { url, relay -> + relay.connectAndSyncFiltersIfDisconnected() + } + + fun disconnect() = + relays.forEach { url, relay -> + relay.disconnect() + } + + fun sendRequest( + subId: String, + filters: List, + ) { + relays.forEach { url, relay -> + val filters = filters.mapNotNull { it.toFilter(url) } + if (filters.isNotEmpty()) { + relay.sendRequest(subId, filters) + } + } + } + + fun sendCounter( + subId: String, + filters: List, + ) { + relays.forEach { url, relay -> + val filters = filters.mapNotNull { it.toFilter(url) } + if (filters.isNotEmpty()) { + relay.sendRequest(subId, filters) + } + } + } + + fun close(subscriptionId: String) = + relays.forEach { url, relay -> + relay.close(subscriptionId) + } + + fun send( + signedEvent: Event, + list: Set, + ) { + list.forEach { + getOrCreateRelay(it).send(signedEvent) + } + } + + // -------------------- + // Pool Maintenance + // -------------------- + fun getOrCreateRelay(relay: NormalizedRelayUrl) = relays.getOrCreate(relay, createNewRelay) + + fun createRelayIfAbsent(relay: NormalizedRelayUrl): Boolean = relays.createIfAbsent(relay, createNewRelay) + + /** + * Updates the pool of relays without disconnecting the existing ones. + */ + fun updatePool(newRelays: Set) { + val toRemove = relays.keys() - newRelays + var atLeastOne = false + + newRelays.forEach { + if (createRelayIfAbsent(it)) { + atLeastOne = true + } + } + + toRemove.forEach { + if (removeRelayInner(it)) { + atLeastOne = true + } + } + + if (atLeastOne) { + updateStatus() + } + } + + fun addRelay(relay: NormalizedRelayUrl): IRelayClient { + if (createRelayIfAbsent(relay)) { + updateStatus() + } + return getOrCreateRelay(relay) + } + + fun addAllRelays(relayList: List) { + var atLeastOne = false + relayList.forEach { + if (createRelayIfAbsent(it)) { + atLeastOne = true + } + } + if (atLeastOne) { + updateStatus() + } + } + + private fun removeRelayInner(relay: NormalizedRelayUrl): Boolean { + val relayInPool = relays.remove(relay) + if (relayInPool != null) { + relayInPool.disconnect() + return true + } + return false + } + + fun removeRelay(relay: NormalizedRelayUrl) { + if (removeRelayInner(relay)) { + updateStatus() + } + } + + fun removeAllRelays() { + if (relays.size() > 0) { + disconnect() + relays.clear() + updateStatus() + } + } + + // -------------------- + // Listener Interceptor + // -------------------- + + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + listener.onEvent(relay, subId, event, arrivalTime, afterEOSE) + } + + override fun onError( + relay: IRelayClient, + subId: String, + error: Error, + ) { + listener.onError(relay, subId, error) + updateStatus() + } + + override fun onEOSE( + relay: IRelayClient, + subId: String, + arrivalTime: Long, + ) { + listener.onEOSE(relay, subId, arrivalTime) + updateStatus() + } + + override fun onRelayStateChange( + relay: IRelayClient, + type: RelayState, + ) { + listener.onRelayStateChange(relay, type) + updateStatus() + } + + override fun onSendResponse( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) = listener.onSendResponse(relay, eventId, success, message) + + override fun onAuth( + relay: IRelayClient, + challenge: String, + ) = listener.onAuth(relay, challenge) + + override fun onNotify( + relay: IRelayClient, + description: String, + ) = listener.onNotify(relay, description) + + override fun onClosed( + relay: IRelayClient, + subId: String, + message: String, + ) = listener.onClosed(relay, subId, message) + + override fun onSend( + relay: IRelayClient, + msg: String, + success: Boolean, + ) = listener.onSend(relay, msg, success) + + override fun onBeforeSend( + relay: IRelayClient, + event: Event, + ) = listener.onBeforeSend(relay, event) + + // --------------- + // STATUS Reports + // --------------- + + fun availableRelays(): Set = relays.keys() + + fun connectedRelays(): Set = + relays.mapNotNullIntoSet { url, relay -> + if (relay.isConnected()) { + url + } else { + null + } + } + + private fun updateStatus() { + val connected = connectedRelays() + val available = availableRelays() + if (_statusFlow.value.connected != connected || _statusFlow.value.available != available) { + _statusFlow.tryEmit(RelayPoolStatus(connected, available)) + } + } + + @Immutable + data class RelayPoolStatus( + val connected: Set = emptySet(), + val available: Set = emptySet(), + val isConnected: Boolean = connected.isNotEmpty(), + ) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/IRelayClient.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/IRelayClient.kt new file mode 100644 index 000000000..76b562722 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/IRelayClient.kt @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2025 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.quartz.nip01Core.relay.client.single + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent + +interface IRelayClient { + val url: NormalizedRelayUrl + + fun connect() + + fun needsToReconnect(): Boolean + + fun connectAndRunAfterSync(onConnected: () -> Unit) + + fun connectAndSyncFiltersIfDisconnected() + + fun isConnected(): Boolean + + fun sendRequest( + subId: String, + filters: List, + ) + + fun sendCount( + subId: String, + filters: List, + ) + + fun send(event: Event) + + fun sendAuth(signedEvent: RelayAuthEvent) + + fun sendEvent(event: Event) + + fun close(subscriptionId: String) + + fun disconnect() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/Subscription.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/Subscription.kt similarity index 86% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/Subscription.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/Subscription.kt index 29e428b4a..c2bcf2006 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/Subscription.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/Subscription.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.quartz.nip01Core.relay +package com.vitorpamplona.quartz.nip01Core.relay.client.single import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import java.util.UUID +import com.vitorpamplona.quartz.utils.RandomInstance + +fun newSubId() = RandomInstance.randomChars(6) class Subscription( - val id: String = UUID.randomUUID().toString().substring(0, 4), + val id: String = newSubId(), val filters: List, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt similarity index 54% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index ba3c98f88..1aed37924 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SimpleClientRelay.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -18,11 +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.quartz.nip01Core.relay +package com.vitorpamplona.quartz.nip01Core.relay.client.single.basic import android.util.Log import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage @@ -37,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder @@ -46,13 +51,13 @@ import com.vitorpamplona.quartz.utils.bytesUsedInMemory import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.cancellation.CancellationException -class SimpleClientRelay( - val url: String, +open class BasicRelayClient( + override val url: NormalizedRelayUrl, val socketBuilder: WebsocketBuilder, - val subs: SubscriptionCollection, - val listener: Listener, + val listener: IRelayClientListener, val stats: RelayStat = RelayStat(), -) { + val defaultOnConnect: (BasicRelayClient) -> Unit = { }, +) : IRelayClient { companion object { // waits 3 minutes to reconnect once things fail const val DELAY_TO_RECONNECT_IN_MSECS = 500L @@ -70,38 +75,24 @@ class SimpleClientRelay( private val authResponseWatcher = mutableMapOf() private val authChallengesSent = mutableSetOf() - /** - * Auth procedures require us to keep track of the outgoing events - * to make sure the relay waits for the auth to finish and send them. - */ - private val outboxCache = mutableMapOf() - private var connectingMutex = AtomicBoolean() private val parser = ToClientParser() fun isConnectionStarted(): Boolean = socket != null - fun isConnected(): Boolean = socket != null && isReady + override fun isConnected(): Boolean = socket != null && isReady - fun connect() = connectAndRunOverride(::sendEverything) + override fun needsToReconnect() = socket?.needsReconnect() ?: true - fun sendEverything() { - renewSubscriptions() - sendOutbox() - } - - fun sendOutbox() { - synchronized(outboxCache) { - outboxCache.values.forEach { - send(it) - } - } - } - - fun connectAndRunAfterSync(onConnected: () -> Unit) { + override fun connect() = connectAndRunOverride { - sendEverything() + defaultOnConnect(this) + } + + override fun connectAndRunAfterSync(onConnected: () -> Unit) { + connectAndRunOverride { + defaultOnConnect(this) onConnected() } } @@ -122,11 +113,12 @@ class SimpleClientRelay( lastConnectTentative = TimeUtils.now() - socket = socketBuilder.build(url, RelayListener(onConnected)) + socket = socketBuilder.build(url, MyWebsocketListener(onConnected)) socket?.connect() } catch (e: Exception) { if (e is CancellationException) throw e + Log.w("Relay", "Relay Crash before connedting $url", e) stats.newError(e.message ?: "Error trying to connect: ${e.javaClass.simpleName}") markConnectionAsClosed() @@ -136,7 +128,7 @@ class SimpleClientRelay( } } - inner class RelayListener( + inner class MyWebsocketListener( val onConnected: () -> Unit, ) : WebSocketListener { override fun onOpen( @@ -147,22 +139,30 @@ class SimpleClientRelay( markConnectionAsReady(pingMillis, compression) - // Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url") onConnected() - listener.onRelayStateChange(this@SimpleClientRelay, RelayState.CONNECTED) + listener.onRelayStateChange(this@BasicRelayClient, RelayState.CONNECTED) } override fun onMessage(text: String) { stats.addBytesReceived(text.bytesUsedInMemory()) try { - processNewRelayMessage(text) + when (val msg = parser.parse(text)) { + is EventMessage -> processEvent(msg) + is EoseMessage -> processEose(msg) + is NoticeMessage -> processNotice(msg) + is OkMessage -> processOk(msg, onConnected) + is AuthMessage -> processAuth(msg) + is NotifyMessage -> processNotify(msg) + is ClosedMessage -> processClosed(msg) + else -> processUnkownMessage(text) + } } catch (e: Throwable) { if (e is CancellationException) throw e stats.newError("Error processing: $text") - Log.e("Relay", "Error processing: $text") - listener.onError(this@SimpleClientRelay, "", Error("Error processing $text")) + Log.e("Relay", "Error processing: $text from ${url.url}") + listener.onError(this@BasicRelayClient, "", Error("Error processing $text")) } } @@ -172,7 +172,7 @@ class SimpleClientRelay( ) { Log.w("Relay", "Relay onClosing $url: $reason") - listener.onRelayStateChange(this@SimpleClientRelay, RelayState.DISCONNECTING) + listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTING) } override fun onClosed( @@ -183,14 +183,15 @@ class SimpleClientRelay( Log.w("Relay", "Relay onClosed $url: $reason") - listener.onRelayStateChange(this@SimpleClientRelay, RelayState.DISCONNECTED) + listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTED) } override fun onFailure( t: Throwable, + code: Int?, response: String?, ) { - socket?.cancel() // 1000, "Normal close" + socket?.disconnect() // 1000, "Normal close" // checks if this is an actual failure. Closing the socket generates an onFailure as well. if (!(socket == null && (t.message == "Socket is closed" || t.message == "Socket closed"))) { @@ -200,12 +201,12 @@ class SimpleClientRelay( // Failures disconnect the relay. markConnectionAsClosed() - Log.w("Relay", "Relay onFailure $url, $response $response ${t.message} $socket") + Log.w("Relay", "Relay onFailure $url, $code $response ${t.message} $socket") t.printStackTrace() listener.onError( - this@SimpleClientRelay, + this@BasicRelayClient, "", - Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t), + Error("WebSocket Failure. Response: $code $response. Exception: ${t.message}", t), ) } } @@ -231,73 +232,78 @@ class SimpleClientRelay( this.resetEOSEStatuses() } - fun processNewRelayMessage(newMessage: String) { - when (val msg = parser.parse(newMessage)) { - is EventMessage -> { - listener.onEvent(this, msg.subId, msg.event, TimeUtils.now(), afterEOSEPerSubscription[msg.subId] == true) - } - is EoseMessage -> { - // Log.w("Relay", "Relay onEOSE $url $newMessage") - afterEOSEPerSubscription[msg.subId] = true - listener.onEOSE(this, msg.subId, TimeUtils.now()) - } - is NoticeMessage -> { - // Log.w("Relay", "Relay onNotice $url, $newMessage") - stats.newNotice(msg.message) - listener.onError(this@SimpleClientRelay, msg.message, Error("Relay sent notice: $msg.message")) - } - is OkMessage -> { - Log.w("Relay", "Relay on OK $url, $newMessage") - - // if this is the OK of an auth event, renew all subscriptions and resend all outgoing events. - if (authResponseWatcher.containsKey(msg.eventId)) { - val wasAlreadyAuthenticated = authResponseWatcher[msg.eventId] - authResponseWatcher.put(msg.eventId, msg.success) - if (wasAlreadyAuthenticated != true && msg.success) { - sendEverything() - } - } - - // remove from cache for any error that is not an auth required error. - // for auth required, we will do the auth and try to send again. - if (outboxCache.contains(msg.eventId) && !msg.message.startsWith("auth-required")) { - synchronized(outboxCache) { - outboxCache.remove(msg.eventId) - } - } - - if (!msg.success) { - stats.newNotice("Rejected event ${msg.eventId}: ${msg.message}") - } - - listener.onSendResponse(this@SimpleClientRelay, msg.eventId, msg.success, msg.message) - } - is AuthMessage -> { - // Log.d("Relay", "Relay onAuth $url, $newMessage") - listener.onAuth(this@SimpleClientRelay, msg.challenge) - } - is NotifyMessage -> { - // Log.w("Relay", "Relay onNotify $url, $newMessage") - listener.onNotify(this@SimpleClientRelay, msg.message) - } - is ClosedMessage -> { - afterEOSEPerSubscription[msg.subscriptionId] = false - // Log.w("Relay", "Relay Closed Subscription $url, $newMessage") - listener.onClosed(this@SimpleClientRelay, msg.subscriptionId, msg.message) - } - else -> { - stats.newError("Unsupported message: $newMessage") - Log.w("Relay", "Unsupported message: $newMessage") - listener.onError(this, "", Error("Unsupported message: $newMessage")) - } - } + private fun processEvent(msg: EventMessage) { + listener.onEvent( + relay = this, + subId = msg.subId, + event = msg.event, + arrivalTime = TimeUtils.now(), + afterEOSE = afterEOSEPerSubscription[msg.subId] == true, + ) } - fun disconnect() { + private fun processEose(msg: EoseMessage) { + // Log.w("Relay", "Relay onEOSE $url $newMessage") + afterEOSEPerSubscription[msg.subId] = true + listener.onEOSE(this, msg.subId, TimeUtils.now()) + } + + private fun processNotice(msg: NoticeMessage) { + // Log.w("Relay", "Relay onNotice $url, $newMessage") + stats.newNotice(msg.message) + listener.onError(this@BasicRelayClient, msg.message, Error("Relay sent notice: $msg.message")) + } + + private fun processOk( + msg: OkMessage, + onConnected: () -> Unit, + ) { + Log.w("Relay", "Relay on OK $url, ${msg.eventId} ${msg.success} ${msg.message}") + + // if this is the OK of an auth event, renew all subscriptions and resend all outgoing events. + if (authResponseWatcher.containsKey(msg.eventId)) { + val wasAlreadyAuthenticated = authResponseWatcher[msg.eventId] + authResponseWatcher.put(msg.eventId, msg.success) + if (wasAlreadyAuthenticated != true && msg.success) { + onConnected() + listener.onAuthed(this@BasicRelayClient, msg.eventId, msg.success, msg.message) + } + } + + if (!msg.success) { + stats.newNotice("Rejected event ${msg.eventId}: ${msg.message}") + } + + listener.onSendResponse(this@BasicRelayClient, msg.eventId, msg.success, msg.message) + } + + private fun processAuth(msg: AuthMessage) { + // Log.d("Relay", "Relay onAuth $url, $newMessage") + listener.onAuth(this@BasicRelayClient, msg.challenge) + } + + private fun processNotify(msg: NotifyMessage) { + // Log.w("Relay", "Relay onNotify $url, $newMessage") + listener.onNotify(this@BasicRelayClient, msg.message) + } + + private fun processClosed(msg: ClosedMessage) { + afterEOSEPerSubscription[msg.subscriptionId] = false + // Log.w("Relay", "Relay Closed Subscription $url, $newMessage") + listener.onClosed(this@BasicRelayClient, msg.subscriptionId, msg.message) + } + + private fun processUnkownMessage(newMessage: String) { + stats.newError("Unsupported message: $newMessage") + Log.w("Relay", "Unsupported message: $newMessage") + listener.onError(this, "", Error("Unsupported message: $newMessage")) + } + + override fun disconnect() { Log.d("Relay", "Relay.disconnect $url") lastConnectTentative = 0L // this is not an error, so prepare to reconnect as soon as requested. delayToConnect = DELAY_TO_RECONNECT_IN_MSECS - socket?.cancel() + socket?.disconnect() socket = null isReady = false usingCompression = false @@ -311,15 +317,15 @@ class SimpleClientRelay( authChallengesSent.clear() } - fun sendRequest( - requestId: String, + override fun sendRequest( + subId: String, filters: List, ) { if (isConnectionStarted()) { if (isReady) { if (filters.isNotEmpty()) { - afterEOSEPerSubscription[requestId] = false - writeToSocket(ReqCmd.toJson(requestId, filters)) + afterEOSEPerSubscription[subId] = false + writeToSocket(ReqCmd.Companion.toJson(subId, filters)) } } } else { @@ -332,15 +338,15 @@ class SimpleClientRelay( } } - fun sendCount( - requestId: String, + override fun sendCount( + subId: String, filters: List, ) { if (isConnectionStarted()) { if (isReady) { if (filters.isNotEmpty()) { - afterEOSEPerSubscription[requestId] = false - writeToSocket(CountCmd.toJson(requestId, filters)) + afterEOSEPerSubscription[subId] = false + writeToSocket(CountCmd.Companion.toJson(subId, filters)) } } } else { @@ -353,7 +359,7 @@ class SimpleClientRelay( } } - fun connectAndSendFiltersIfDisconnected() { + override fun connectAndSyncFiltersIfDisconnected() { if (socket == null) { // waits 60 seconds to reconnect after disconnected. if (TimeUtils.now() > lastConnectTentative + delayToConnect) { @@ -363,42 +369,31 @@ class SimpleClientRelay( } } - fun renewSubscriptions() { - // Force update all filters after AUTH. - subs.allSubscriptions().forEach { - sendRequest(requestId = it.id, it.filters) - } - } + override fun send(event: Event) { + listener.onBeforeSend(this@BasicRelayClient, event) - fun send(signedEvent: Event) { - listener.onBeforeSend(this@SimpleClientRelay, signedEvent) - - if (signedEvent is RelayAuthEvent) { - sendAuth(signedEvent) + if (event is RelayAuthEvent) { + sendAuth(event) } else { - sendEvent(signedEvent) + sendEvent(event) } } - fun sendAuth(signedEvent: RelayAuthEvent) { + override fun sendAuth(signedEvent: RelayAuthEvent) { val challenge = signedEvent.challenge() // only send replies to new challenges to avoid infinite loop: if (challenge != null && challenge !in authChallengesSent) { authResponseWatcher[signedEvent.id] = false authChallengesSent.add(challenge) - writeToSocket(AuthCmd.toJson(signedEvent)) + writeToSocket(AuthCmd.Companion.toJson(signedEvent)) } } - fun sendEvent(signedEvent: Event) { - synchronized(outboxCache) { - outboxCache.put(signedEvent.id, signedEvent) - } - + override fun sendEvent(event: Event) { if (isConnectionStarted()) { if (isReady) { - writeToSocket(EventCmd.toJson(signedEvent)) + writeToSocket(EventCmd.Companion.toJson(event)) } } else { // automatically sends all filters after connection is successful. @@ -409,111 +404,20 @@ class SimpleClientRelay( private fun writeToSocket(str: String) { if (socket == null) { listener.onError( - this@SimpleClientRelay, + this@BasicRelayClient, "", Error("Failed to send $str. Relay is not connected."), ) } socket?.let { val result = it.send(str) - listener.onSend(this@SimpleClientRelay, str, result) + listener.onSend(this@BasicRelayClient, str, result) stats.addBytesSent(str.bytesUsedInMemory()) } } - fun close(subscriptionId: String) { - writeToSocket(CloseCmd.toJson(subscriptionId)) + override fun close(subscriptionId: String) { + writeToSocket(CloseCmd.Companion.toJson(subscriptionId)) afterEOSEPerSubscription[subscriptionId] = false } - - interface Listener { - /** - * New Event arrives from the relay. - */ - fun onEvent( - relay: SimpleClientRelay, - subscriptionId: String, - event: Event, - arrivalTime: Long, - afterEOSE: Boolean, - ) - - /** - * New EOSE command arrives for a subscription - */ - fun onEOSE( - relay: SimpleClientRelay, - subscriptionId: String, - arrivalTime: Long, - ) - - /** - * 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, - success: Boolean, - message: String, - ) - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/OutboxProtector.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/OutboxProtector.kt new file mode 100644 index 000000000..80d1d378e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/OutboxProtector.kt @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2025 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.quartz.nip01Core.relay.client.single.simple + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RedirectRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.utils.LargeCache + +class OutboxProtector(listener: IRelayClientListener) : RedirectRelayClientListener(listener) { + /** + * Auth procedures require us to keep track of the outgoing events + * to make sure the relay waits for the auth to finish and send them. + */ + private val outboxCache = LargeCache() + + override fun onRelayStateChange( + relay: IRelayClient, + type: RelayState, + ) { + if (type == RelayState.CONNECTED) { + outboxCache.forEach { id, event -> + relay.sendEvent(event) + } + } + + super.onRelayStateChange(relay, type) + } + + override fun onAuthed( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) { + super.onAuthed(relay, eventId, success, message) + outboxCache.forEach { id, event -> + relay.sendEvent(event) + } + } + + override fun onBeforeSend( + relay: IRelayClient, + event: Event, + ) { + if (event !is RelayAuthEvent) { + outboxCache.put(event.id, event) + } + super.onBeforeSend(relay, event) + } + + override fun onSendResponse( + relay: IRelayClient, + eventId: String, + success: Boolean, + message: String, + ) { + // remove from cache for any error that is not an auth required error. + // for auth required, we will do the auth and try to send again. + if (outboxCache.containsKey(eventId) && !message.startsWith("auth-required")) { + outboxCache.remove(eventId) + } + + super.onSendResponse(relay, eventId, success, message) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/SimpleRelayClient.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/SimpleRelayClient.kt new file mode 100644 index 000000000..35d1bea22 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/SimpleRelayClient.kt @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2025 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.quartz.nip01Core.relay.client.single.simple + +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder + +/** + * This relay client saves any event that will be sent in an outbox + * waits for auth and sends it again to make sure it is delivered. + */ +class SimpleRelayClient( + url: NormalizedRelayUrl, + socketBuilder: WebsocketBuilder, + listener: IRelayClientListener, + stats: RelayStat = RelayStat(), + defaultOnConnect: (BasicRelayClient) -> Unit = { }, +) : IRelayClient by BasicRelayClient( + url, + socketBuilder, + OutboxProtector(listener), + stats, + defaultOnConnect, + ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayStat.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt similarity index 97% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayStat.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt index aa64801c3..fd266e0ff 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/RelayStat.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.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.quartz.nip01Core.relay +package com.vitorpamplona.quartz.nip01Core.relay.client.stats import androidx.collection.LruCache import com.vitorpamplona.quartz.utils.TimeUtils diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayStats.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt similarity index 74% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayStats.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt index 033f6673e..b12e95e2c 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayStats.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt @@ -18,52 +18,56 @@ * 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.quartz.nip01Core.relay.client.stats -import com.vitorpamplona.quartz.nip01Core.relay.RelayStat +import android.util.LruCache +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl object RelayStats { - private val innerCache = mutableMapOf() + private val innerCache = + object : LruCache(1000) { + override fun create(key: NormalizedRelayUrl?) = RelayStat() + } - fun get(url: String): RelayStat = innerCache.getOrPut(url) { RelayStat() } + fun get(url: NormalizedRelayUrl): RelayStat = innerCache.get(url) fun addBytesReceived( - url: String, + url: NormalizedRelayUrl, bytesUsedInMemory: Long, ) { get(url).addBytesReceived(bytesUsedInMemory) } fun addBytesSent( - url: String, + url: NormalizedRelayUrl, bytesUsedInMemory: Long, ) { get(url).addBytesSent(bytesUsedInMemory) } fun newError( - url: String, + url: NormalizedRelayUrl, error: String?, ) { get(url).newError(error) } fun newNotice( - url: String, + url: NormalizedRelayUrl, notice: String?, ) { get(url).newNotice(notice) } fun setPing( - url: String, + url: NormalizedRelayUrl, pingInMs: Long, ) { get(url).pingInMs = pingInMs } fun newSpam( - url: String, + url: NormalizedRelayUrl, explanation: String, ) { get(url).newSpam(explanation) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt new file mode 100644 index 000000000..38abf28e7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2025 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.quartz.nip01Core.relay.normalizer + +data class NormalizedRelayUrl(val url: String) : Comparable { + override fun compareTo(other: NormalizedRelayUrl) = url.compareTo(other.url) +} + +fun NormalizedRelayUrl.displayUrl() = + url + .removePrefix("wss://") + .removePrefix("ws://") + .removeSuffix("/") + +fun NormalizedRelayUrl.toHttp() = + if (url.startsWith("wss://")) { + "https${url.drop(3)}" + } else if (url.startsWith("ws://")) { + "https${url.drop(2)}" + } else { + "https://$url" + } + +fun NormalizedRelayUrl.isOnion() = url.endsWith(".onion/") + +fun NormalizedRelayUrl.isLocalHost() = url.contains("127.0.0.1") || url.contains("localhost") diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt new file mode 100644 index 000000000..f42e8b7d8 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2025 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.quartz.nip01Core.relay.normalizer + +import androidx.collection.LruCache +import org.czeal.rfc3986.URIReference + +val normalizedUrls = LruCache(5000) + +class RelayUrlNormalizer { + companion object { + fun isLocalHost(url: String) = url.contains("127.0.0.1") || url.contains("localhost") + + fun isOnion(url: String) = url.endsWith(".onion") || url.endsWith(".onion/") + + private fun norm(url: String) = NormalizedRelayUrl(URIReference.parse(url).normalize().toString().intern()) + + fun fix(url: String): String { + val trimmed = url.trim() + + // fast + if (trimmed.length > 4 && trimmed[0] == 'w' && trimmed[1] == 's') { + if (trimmed[2] == 's' && trimmed[3] == ':' && trimmed[4] == '/' && trimmed[5] == '/') { + return trimmed + } else if (trimmed[2] == ':' && trimmed[3] == '/' && trimmed[4] == '/') { + return trimmed + } + } + + // fast + if (trimmed.length > 8 && trimmed[0] == 'h' && trimmed[1] == 't' && trimmed[2] == 't' && trimmed[3] == 'p') { + if (trimmed[4] == 's' && trimmed[5] == ':' && trimmed[6] == '/' && trimmed[7] == '/') { + // https:// + return "wss://${trimmed.drop(8)}" + } else if (trimmed[4] == ':' && trimmed[5] == '/' && trimmed[6] == '/') { + // http:// + return "ws://${trimmed.drop(7)}" + } + } + + return if (isOnion(trimmed) || isLocalHost(trimmed)) { + "ws://$trimmed" + } else { + "wss://$trimmed" + } + } + + fun normalize(url: String): NormalizedRelayUrl { + normalizedUrls.get(url)?.let { return it } + + return try { + val normalized = norm(fix(url)) + normalizedUrls.put(url, normalized) + normalized + } catch (e: Exception) { + NormalizedRelayUrl(url) + } + } + + fun normalizeOrNull(url: String): NormalizedRelayUrl? { + normalizedUrls.get(url)?.let { return it } + + return try { + val normalized = norm(fix(url)) + normalizedUrls.put(url, normalized) + normalized + } catch (e: Exception) { + null + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocket.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocket.kt index a11466a41..090617cba 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocket.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocket.kt @@ -21,9 +21,11 @@ package com.vitorpamplona.quartz.nip01Core.relay.sockets interface WebSocket { + fun needsReconnect(): Boolean + fun connect() - fun cancel() + fun disconnect() fun send(msg: String): Boolean } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocketListener.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocketListener.kt index 32f8808b3..acebf8d6b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocketListener.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocketListener.kt @@ -40,6 +40,7 @@ interface WebSocketListener { fun onFailure( t: Throwable, + code: Int?, response: String?, ) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt index ca813cff6..b2ca9aa9a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.sockets +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + interface WebsocketBuilder { fun build( - url: String, + url: NormalizedRelayUrl, out: WebSocketListener, ): WebSocket } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt index a36474c75..0dfe23c98 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt @@ -31,6 +31,8 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent abstract class NostrSigner( val pubKey: HexKey, ) { + abstract fun isWriteable(): Boolean + fun sign( ev: EventTemplate, onReady: (T) -> Unit, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt index 53c37c1cf..9a840abff 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt @@ -32,6 +32,8 @@ class NostrSignerInternal( ) : NostrSigner(keyPair.pubKey.toHexKey()) { val signerSync = NostrSignerSync(keyPair) + override fun isWriteable(): Boolean = keyPair.privKey != null + override fun sign( createdAt: Long, kind: Int, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/ATag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/ATag.kt index 8ceb598a5..22cdf14e3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/ATag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/ATag.kt @@ -24,33 +24,32 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.ensure import com.vitorpamplona.quartz.utils.pointerSizeInBytes -import com.vitorpamplona.quartz.utils.removeTrailingNullsAndEmptyOthers @Immutable data class ATag( val kind: Int, - val pubKeyHex: String, + val pubKeyHex: HexKey, val dTag: String, - val relay: String? = null, + val relay: NormalizedRelayUrl? = null, ) { - constructor(address: Address, relayHint: String? = null) : this(address.kind, address.pubKeyHex, address.dTag, relayHint) + constructor(address: Address, relayHint: NormalizedRelayUrl? = null) : this(address.kind, address.pubKeyHex, address.dTag, relayHint) fun countMemory(): Long = 5 * pointerSizeInBytes + // 7 fields, 4 bytes each reference (32bit) 8L + // kind pubKeyHex.bytesUsedInMemory() + dTag.bytesUsedInMemory() + - (relay?.bytesUsedInMemory() ?: 0) + (relay?.url?.bytesUsedInMemory() ?: 0) fun toTag() = Address.assemble(kind, pubKeyHex, dTag) - fun toATagArray() = removeTrailingNullsAndEmptyOthers(TAG_NAME, toTag(), relay) - - fun toQTagArray() = removeTrailingNullsAndEmptyOthers("q", toTag(), relay) + fun toATagArray() = assemble(toTag(), relay) companion object { const val TAG_NAME = "a" @@ -109,7 +108,14 @@ data class ATag( fun parse( aTagId: String, relay: String?, - ) = Address.parse(aTagId)?.let { ATag(it.kind, it.pubKeyHex, it.dTag, relay) } + ) = Address.parse(aTagId)?.let { + ATag( + it.kind, + it.pubKeyHex, + it.dTag, + relay?.let { RelayUrlNormalizer.normalizeOrNull(it) }, + ) + } @JvmStatic fun parse(tag: Array): ATag? { @@ -150,21 +156,31 @@ data class ATag( ensure(tag[1].isNotEmpty()) { return null } ensure(tag[1].contains(':')) { return null } ensure(tag[2].isNotEmpty()) { return null } - return AddressHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) } @JvmStatic fun assemble( aTagId: HexKey, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, aTagId, relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url) + + @JvmStatic + fun assemble( + address: Address, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url) @JvmStatic fun assemble( kind: Int, pubKey: String, dTag: String, - relay: String?, + relay: NormalizedRelayUrl?, ) = assemble(Address.assemble(kind, pubKey, dTag), relay) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/Address.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/Address.kt index c3ccd850a..9ac889a43 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/Address.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/Address.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.nip01Core.tags.addressables import android.util.Log import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes @@ -30,7 +32,7 @@ data class Address( val kind: Int, val pubKeyHex: HexKey, val dTag: String, -) { +) : Comparable
{ fun toValue() = assemble(kind, pubKeyHex, dTag) fun countMemory(): Long = @@ -39,6 +41,20 @@ data class Address( pubKeyHex.bytesUsedInMemory() + dTag.bytesUsedInMemory() + override fun compareTo(other: Address): Int { + val result = kind.compareTo(other.kind) + if (result == 0) { + val result2 = pubKeyHex.compareTo(other.pubKeyHex) + if (result2 == 0) { + return dTag.compareTo(other.dTag) + } else { + return result2 + } + } else { + return result + } + } + companion object { fun assemble( kind: Int, @@ -53,8 +69,18 @@ data class Address( if (parts.size > 1 && parts[1].length == 64 && Hex.isHex(parts[1])) { Address(parts[0].toInt(), parts[1], parts[2]) } else { - Log.w("AddressableId", "Error parsing. Pubkey is not hex: $addressId") - null + if (addressId.startsWith("naddr1")) { + val addr = Nip19Parser.uriToRoute(addressId)?.entity + if (addr is NAddress) { + addr.address() + } else { + Log.w("AddressableId", "Error parsing. Pubkey is not hex: $addressId") + null + } + } else { + Log.w("AddressableId", "Error parsing. Pubkey is not hex: $addressId") + null + } } } catch (t: Throwable) { Log.e("AddressableId", "Error parsing: $addressId: ${t.message}", t) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/EventExt.kt index 2d529e766..ef28d4089 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/EventExt.kt @@ -26,9 +26,9 @@ fun Event.mapTaggedAddress(map: (address: String) -> R) = tags.mapTaggedAddr fun Event.firstIsTaggedAddressableNote(addressableNotes: Set) = tags.firstIsTaggedAddressableNote(addressableNotes) -fun Event.isTaggedAddressableNote(idHex: String) = tags.isTaggedAddressableNote(idHex) +fun Event.isTaggedAddressableNote(addressId: String) = tags.isTaggedAddressableNote(addressId) -fun Event.isTaggedAddressableNotes(idHexes: Set) = tags.isTaggedAddressableNotes(idHexes) +fun Event.isTaggedAddressableNotes(addressIds: Set) = tags.isTaggedAddressableNotes(addressIds) fun Event.isTaggedAddressableKind(kind: Int) = tags.isTaggedAddressableKind(kind) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayBuilderExt.kt index 0ef9971c2..3b494e0e9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/TagArrayBuilderExt.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.tags.addressables import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip18Reposts.quotes.toQTagArray fun TagArrayBuilder.aTag(tag: ATag) = add(tag.toATagArray()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/ETag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/ETag.kt index 7c1d7b318..f9901163f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/ETag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/ETag.kt @@ -24,6 +24,8 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory @@ -34,10 +36,10 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes data class ETag( override val eventId: HexKey, ) : GenericETag { - override var relay: String? = null + override var relay: NormalizedRelayUrl? = null override var author: HexKey? = null - constructor(eventId: HexKey, relayHint: String? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { + constructor(eventId: HexKey, relayHint: NormalizedRelayUrl? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { this.relay = relayHint this.author = authorPubKeyHex } @@ -45,16 +47,12 @@ data class ETag( fun countMemory(): Long = 3 * pointerSizeInBytes + // 3 fields, 4 bytes each reference (32bit) eventId.bytesUsedInMemory() + - (relay?.bytesUsedInMemory() ?: 0) + + (relay?.url?.bytesUsedInMemory() ?: 0) + (author?.bytesUsedInMemory() ?: 0) fun toNEvent(): String = NEvent.create(eventId, author, null, relay) - override fun toTagArray() = toNamedTagArray(TAG_NAME) - - fun toQTagArray() = toNamedTagArray("q") - - fun toNamedTagArray(key: String) = arrayOfNotNull(key, eventId, relay, author) + override fun toTagArray() = assemble(eventId, relay, author) companion object { const val TAG_NAME = "e" @@ -73,7 +71,9 @@ data class ETag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ETag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + return ETag(tag[1], hint, tag.getOrNull(3)) } @JvmStatic @@ -90,14 +90,18 @@ data class ETag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return EventIdHint(tag[1], tag[2]) + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(hint != null) { return null } + + return EventIdHint(tag[1], hint) } @JvmStatic fun assemble( eventId: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, author: HexKey?, - ) = arrayOfNotNull(TAG_NAME, eventId, relay, author) + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, author) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventReference.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventReference.kt index d5ba844f2..6b86db0fc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventReference.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/EventReference.kt @@ -21,9 +21,10 @@ package com.vitorpamplona.quartz.nip01Core.tags.events import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class EventReference( val eventId: HexKey, val author: HexKey?, - val relayHint: String?, + val relayHint: NormalizedRelayUrl?, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/GenericETag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/GenericETag.kt index 2514b3bcf..4d2fb2426 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/GenericETag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/GenericETag.kt @@ -21,10 +21,11 @@ package com.vitorpamplona.quartz.nip01Core.tags.events import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl interface GenericETag { val eventId: HexKey - val relay: String? + val relay: NormalizedRelayUrl? val author: HexKey? fun toTagArray(): Array diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayBuilderExt.kt index 860407679..d45141c0e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/events/TagArrayBuilderExt.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.tags.events import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip18Reposts.quotes.toQTagArray fun TagArrayBuilder.eTag(tag: ETag) = add(tag.toTagArray()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt index c82e0e896..581e65a05 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt @@ -30,6 +30,16 @@ class HashtagTag { @JvmStatic fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + fun isTagged( + tag: Array, + hashtag: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == hashtag + + fun isAnyTagged( + tag: Array, + hashtags: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in hashtags + @JvmStatic fun parse(tag: Array): String? { ensure(tag.has(1)) { return null } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PTag.kt index 6d0204a2e..4695d5b1f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PTag.kt @@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.utils.arrayOfNotNull @@ -37,12 +39,12 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable data class PTag( override val pubKey: HexKey, - override val relayHint: String? = null, + override val relayHint: NormalizedRelayUrl? = null, ) : PubKeyReferenceTag { fun countMemory(): Long = 2 * pointerSizeInBytes + // 2 fields, 4 bytes each reference (32bit) pubKey.bytesUsedInMemory() + - (relayHint?.bytesUsedInMemory() ?: 0) + (relayHint?.url?.bytesUsedInMemory() ?: 0) fun toNProfile(): String = NProfile.create(pubKey, relayHint?.let { listOf(it) } ?: emptyList()) @@ -63,7 +65,10 @@ data class PTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return PTag(tag[1], tag.getOrNull(2)) + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return PTag(tag[1], hint) } @JvmStatic @@ -83,13 +88,17 @@ data class PTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return PubKeyHint(tag[1], tag[2]) + + val normalized = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(normalized != null) { return null } + + return PubKeyHint(tag[1], normalized) } @JvmStatic fun assemble( pubkey: HexKey, - relayHint: String?, - ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint) + relayHint: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PubKeyReferenceTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PubKeyReferenceTag.kt index 99da4d774..27750180b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PubKeyReferenceTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/PubKeyReferenceTag.kt @@ -21,8 +21,9 @@ package com.vitorpamplona.quartz.nip01Core.tags.people import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl interface PubKeyReferenceTag { val pubKey: HexKey - val relayHint: String? + val relayHint: NormalizedRelayUrl? } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayBuilderExt.kt index 023a23980..d622c41b2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/people/TagArrayBuilderExt.kt @@ -23,10 +23,11 @@ package com.vitorpamplona.quartz.nip01Core.tags.people import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun TagArrayBuilder.pTag( pubkey: HexKey, - relayHint: String? = null, + relayHint: NormalizedRelayUrl? = null, ) = add(PTag.assemble(pubkey, relayHint)) fun TagArrayBuilder.pTagIds(tag: Set) = addAll(tag.map { PTag.assemble(it, null) }) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt index 92420fa07..c91a6550b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag @@ -74,7 +76,20 @@ class ContactListEvent( fun followsTags() = hashtags() - fun relays(): Map? = RelaySet.parse(content) + fun relays(): Map? { + val regular = RelaySet.parse(content) + + val normalized = mutableMapOf() + + regular?.forEach { + val key = RelayUrlNormalizer.normalizeOrNull(it.key) + if (key != null) { + normalized.put(key, it.value) + } + } + + return normalized + } companion object { const val KIND = 3 @@ -252,7 +267,7 @@ class ContactListEvent( content = earlierVersion.content, tags = earlierVersion.tags.plus( - element = listOfNotNull("a", aTag.toTag(), aTag.relay).toTypedArray(), + element = aTag.toATagArray(), ), signer = signer, createdAt = createdAt, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt index b1dbef7e0..b67f628b7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory @@ -36,12 +38,12 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes data class ContactTag( val pubKey: HexKey, ) { - var relayUri: String? = null + var relayUri: NormalizedRelayUrl? = null var petname: String? = null constructor( pubKey: HexKey, - relayHint: String?, + relayHint: NormalizedRelayUrl?, petname: String?, ) : this(pubKey) { this.relayUri = relayHint @@ -51,7 +53,7 @@ data class ContactTag( fun countMemory(): Long = 3 * pointerSizeInBytes + pubKey.bytesUsedInMemory() + - (relayUri?.bytesUsedInMemory() ?: 0) + + (relayUri?.url?.bytesUsedInMemory() ?: 0) + (petname?.bytesUsedInMemory() ?: 0) fun toTagArray() = assemble(pubKey, relayUri, petname) @@ -67,7 +69,10 @@ data class ContactTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ContactTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ContactTag(tag[1], hint, tag.getOrNull(3)) } @JvmStatic @@ -75,8 +80,11 @@ data class ContactTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + return try { - ContactTag(decodePublicKey(tag[1]).toHexKey(), tag.getOrNull(2), tag.getOrNull(3)) + ContactTag(decodePublicKey(tag[1]).toHexKey(), hint, tag.getOrNull(3)) } catch (e: Exception) { Log.w("ContactTag", "Can't parse contact list p-tag ${tag.joinToString(", ")}", e) null @@ -110,14 +118,18 @@ data class ContactTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return PubKeyHint(tag[1], tag[2]) + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) } @JvmStatic fun assemble( pubkey: HexKey, - relayUri: String? = null, + relayUri: NormalizedRelayUrl? = null, petname: String? = null, - ) = arrayOfNotNull(TAG_NAME, pubkey, relayUri, petname) + ) = arrayOfNotNull(TAG_NAME, pubkey, relayUri?.url, petname) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt index f130f910a..e44910158 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt @@ -58,6 +58,12 @@ open class BaseThreadedEvent( EventHintProvider, AddressHintProvider, PubKeyHintProvider { + @Transient + private var citedUsersCache: Set? = null + + @Transient + private var citedNotesCache: Set? = null + override fun eventHints() = tags.mapNotNull(MarkedETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) @@ -107,10 +113,6 @@ open class BaseThreadedEvent( return newStyleReply ?: newStyleRoot ?: oldStylePositional } - @Transient private var citedUsersCache: Set? = null - - @Transient private var citedNotesCache: Set? = null - fun citedUsers(): Set { citedUsersCache?.let { return it diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/MarkedETag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/MarkedETag.kt index 48cd5458f..2c04df5d1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/MarkedETag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/MarkedETag.kt @@ -24,43 +24,30 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.utils.arrayOfNotNull -import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.ensure -import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable data class MarkedETag( override val eventId: HexKey, ) : GenericETag { - override var relay: String? = null - var marker: String? = null + override var relay: NormalizedRelayUrl? = null + var marker: MARKER? = null override var author: HexKey? = null - constructor(eventId: HexKey, relayHint: String? = null, marker: String? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { + constructor(eventId: HexKey, relayHint: NormalizedRelayUrl? = null, marker: MARKER? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { this.relay = relayHint this.marker = marker this.author = authorPubKeyHex } - constructor(eventId: HexKey, relayHint: String? = null, marker: MARKER? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { - this.relay = relayHint - this.marker = marker?.code - this.author = authorPubKeyHex - } - - fun countMemory(): Long = - 4 * pointerSizeInBytes + // 3 fields, 4 bytes each reference (32bit) - eventId.bytesUsedInMemory() + - (relay?.bytesUsedInMemory() ?: 0) + - (marker?.bytesUsedInMemory() ?: 0) + - (author?.bytesUsedInMemory() ?: 0) - fun toNEvent(): String = NEvent.create(eventId, author, null, relay) - override fun toTagArray() = arrayOfNotNull(TAG_NAME, eventId, relay, marker, author) + override fun toTagArray() = assemble(eventId, relay, marker, author) enum class MARKER( val code: String, @@ -69,6 +56,19 @@ data class MarkedETag( REPLY("reply"), MENTION("mention"), FORK("fork"), + ; + + companion object { + fun parse(code: String): MARKER? { + return when (code) { + ROOT.code -> ROOT + REPLY.code -> REPLY + MENTION.code -> MENTION + FORK.code -> FORK + else -> null + } + } + } } companion object { @@ -92,11 +92,9 @@ data class MarkedETag( return MarkedETag( tag[ORDER_EVT_ID], - tag[ORDER_RELAY], - tag[ORDER_MARKER], - tag.getOrNull( - ORDER_PUBKEY, - ), + RelayUrlNormalizer.normalizeOrNull(tag[ORDER_RELAY]), + MARKER.parse(tag[ORDER_MARKER]), + tag.getOrNull(ORDER_PUBKEY), ) } @@ -112,38 +110,40 @@ data class MarkedETag( if (tag.size >= 2 && tag[0] == TAG_NAME) { if (tag.size <= 3) { // simple case ["e", "id", "relay"] - MarkedETag(tag[1], tag.getOrNull(2), null as String?, null) + MarkedETag(tag[1], tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }, null, null) } else if (tag.size == 4) { + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) if (tag[3].isEmpty()) { // empty tags ["e", "id", "relay", ""] - MarkedETag(tag[1], tag[2], null as String?, null) + MarkedETag(tag[1], relayHint, null, null) } else if (tag[3].length == 64) { // updated case with pubkey instead of marker ["e", "id", "relay", "pubkey"] - MarkedETag(tag[1], tag[2], null as String?, tag[3]) + MarkedETag(tag[1], relayHint, null, tag[3]) } else if (tag[3] == MARKER.ROOT.code) { // corrent root ["e", "id", "relay", "root"] - MarkedETag(tag[1], tag[2], tag[3]) + MarkedETag(tag[1], relayHint, MARKER.ROOT) } else if (tag[3] == MARKER.REPLY.code) { // correct reply ["e", "id", "relay", "reply"] - MarkedETag(tag[1], tag[2], tag[3]) + MarkedETag(tag[1], relayHint, MARKER.REPLY) } else { // ignore "mention" and "fork" markers null } } else { + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) // tag.size >= 5 if (tag[3].isEmpty()) { // empty tags ["e", "id", "relay", "", "pubkey"] - MarkedETag(tag[1], tag[2], null as String?, tag[4]) + MarkedETag(tag[1], relayHint, null, tag[4]) } else if (tag[3].length == 64) { // updated case with pubkey instead of marker ["e", "id", "relay", "pubkey"] - MarkedETag(tag[1], tag[2], null as String?, tag[3]) + MarkedETag(tag[1], relayHint, null, tag[3]) } else if (tag[3] == MARKER.ROOT.code) { // corrent root ["e", "id", "relay", "root"] - MarkedETag(tag[1], tag[2], tag[3], tag[4]) + MarkedETag(tag[1], relayHint, MARKER.ROOT, tag[4]) } else if (tag[3] == MARKER.REPLY.code) { // correct reply ["e", "id", "relay", "reply"] - MarkedETag(tag[1], tag[2], tag[3], tag[4]) + MarkedETag(tag[1], relayHint, MARKER.REPLY, tag[4]) } else { // ignore "mention" and "fork" markers null @@ -189,21 +189,24 @@ data class MarkedETag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return EventIdHint(tag[1], tag[2]) + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(hint != null) { return null } + + return EventIdHint(tag[1], hint) } @JvmStatic fun parseRoot(tag: Array): MarkedETag? { if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null if (tag[ORDER_MARKER] != MARKER.ROOT.code) return null + // ["e", id hex, relay hint, marker, pubkey] return MarkedETag( - tag[ORDER_EVT_ID], - tag[ORDER_RELAY], - tag[ORDER_MARKER], - tag.getOrNull( - ORDER_PUBKEY, - ), + eventId = tag[ORDER_EVT_ID], + relayHint = RelayUrlNormalizer.normalizeOrNull(tag[ORDER_RELAY]), + marker = MARKER.ROOT, + authorPubKeyHex = tag.getOrNull(ORDER_PUBKEY), ) } @@ -213,7 +216,7 @@ data class MarkedETag( @JvmStatic fun parseUnmarkedRoot(tag: Array): MarkedETag? = if (tag.size in 2..3 && tag[0] == TAG_NAME) { - MarkedETag(tag[1], tag.getOrNull(2), MARKER.ROOT) + MarkedETag(tag[1], tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }, MARKER.ROOT) } else { null } @@ -225,8 +228,8 @@ data class MarkedETag( // ["e", id hex, relay hint, marker, pubkey] return MarkedETag( tag[ORDER_EVT_ID], - tag[ORDER_RELAY], - tag[ORDER_MARKER], + RelayUrlNormalizer.normalizeOrNull(tag[ORDER_RELAY]), + MARKER.REPLY, tag.getOrNull( ORDER_PUBKEY, ), @@ -239,7 +242,7 @@ data class MarkedETag( @JvmStatic fun parseUnmarkedReply(tag: Array): MarkedETag? = if (tag.size in 2..3 && tag[0] == TAG_NAME) { - MarkedETag(tag[1], tag.getOrNull(2), MARKER.REPLY) + MarkedETag(tag[1], tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }, MARKER.REPLY) } else { null } @@ -255,9 +258,17 @@ data class MarkedETag( @JvmStatic fun assemble( eventId: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, + marker: String?, + author: HexKey?, + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, marker, author) + + @JvmStatic + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl?, marker: MARKER?, author: HexKey?, - ) = arrayOfNotNull(TAG_NAME, eventId, relay, marker?.code, author) + ) = assemble(eventId, relay, marker?.code, author) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/ReplyBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/ReplyBuilder.kt index 2ab00d344..3f77f6678 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/ReplyBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip10Notes/tags/ReplyBuilder.kt @@ -54,7 +54,7 @@ fun prepareETagsAsReplyTo( val branchTags = replyingTo.event.threadTags().filter { it.eventId != rootTag.eventId }.map { - MarkedETag(it.eventId, it.relay, "", it.author) + MarkedETag(it.eventId, it.relay, null, it.author) } val branch = mutableListOf() @@ -83,7 +83,7 @@ fun prepareMarkedETagsAsReplyTo(replyingTo: EventHintBun val branchTags = replyingTo.event.threadTags().filter { it.eventId != rootTag.eventId }.map { - MarkedETag(it.eventId, it.relay, "", it.author) + MarkedETag(it.eventId, it.relay, null, it.author) } val branch = mutableListOf() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt index e4d9c059e..6293e094c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent -import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent.Companion.ALT_DESCRIPTION import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @@ -45,7 +44,7 @@ class ChatMessageEvent( companion object { const val KIND = 14 - const val ALT = "Direct message" + const val ALT_DESCRIPTION = "Direct message" fun build( msg: String, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt index c4ba35d46..ac5a6eb2b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt @@ -23,10 +23,12 @@ package com.vitorpamplona.quartz.nip17Dm.settings import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip17Dm.settings.tags.RelayTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -39,14 +41,7 @@ class ChatMessageRelayListEvent( content: String, sig: HexKey, ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relays(): List = - tags.mapNotNull { - if (it.size > 1 && it[0] == "relay") { - it[1] - } else { - null - } - } + fun relays(): List = tags.mapNotNull(RelayTag::parse) companion object { const val KIND = 10050 @@ -57,26 +52,27 @@ class ChatMessageRelayListEvent( fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) - fun createTagArray(relays: List): Array> = + fun createTagArray(relays: List): Array> = relays .map { - arrayOf("relay", it) - }.plusElement(AltTag.assemble("Relay list to receive private messages")) + RelayTag.assemble(it) + }.plusElement( + AltTag.assemble("Relay list to receive private messages"), + ) .toTypedArray() fun updateRelayList( earlierVersion: ChatMessageRelayListEvent, - relays: List, + relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (ChatMessageRelayListEvent) -> Unit, ) { val tags = - earlierVersion.tags - .filter { it[0] != "relay" } + earlierVersion.tags.filter(RelayTag::notMatch) .plus( relays.map { - arrayOf("relay", it) + RelayTag.assemble(it) }, ).toTypedArray() @@ -84,7 +80,7 @@ class ChatMessageRelayListEvent( } fun createFromScratch( - relays: List, + relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (ChatMessageRelayListEvent) -> Unit, @@ -93,7 +89,7 @@ class ChatMessageRelayListEvent( } fun create( - relays: List, + relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (ChatMessageRelayListEvent) -> Unit, @@ -102,7 +98,7 @@ class ChatMessageRelayListEvent( } fun create( - relays: List, + relays: List, signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), ): ChatMessageRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "") diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/tags/RelayTag.kt new file mode 100644 index 000000000..19a2762cd --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/settings/tags/RelayTag.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 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.quartz.nip17Dm.settings.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +class RelayTag { + companion object { + const val TAG_NAME = "relay" + + @JvmStatic + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun notMatch(tag: Array) = tag.has(0) && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): NormalizedRelayUrl? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) ?: return null + + return relay + } + + @JvmStatic + fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt index 6d76eac17..2439e25cf 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag @@ -86,8 +87,8 @@ class GenericRepostEvent( fun build( boostedPost: Event, - eventSourceRelay: String?, - authorHomeRelay: String?, + eventSourceRelay: NormalizedRelayUrl?, + authorHomeRelay: NormalizedRelayUrl?, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, boostedPost.toJson(), createdAt) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt index d6056b510..05aa903e4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.aTag @@ -84,8 +85,8 @@ class RepostEvent( fun build( boostedPost: Event, - eventSourceRelay: String?, - authorHomeRelay: String?, + eventSourceRelay: NormalizedRelayUrl?, + authorHomeRelay: NormalizedRelayUrl?, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, boostedPost.toJson(), createdAt) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EntityExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EntityExt.kt index 4737876e4..f9e3d746a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EntityExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/EntityExt.kt @@ -21,10 +21,13 @@ package com.vitorpamplona.quartz.nip18Reposts.quotes import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag 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.Note +import org.apache.commons.lang3.text.translate.CharSequenceTranslator.hex fun Note.toQuoteTag() = QEventTag(hex, null, null) @@ -51,3 +54,7 @@ fun NEmbed.toQuoteTagArray() = } else { QEventTag.assemble(event.id, null, event.pubKey) } + +fun ETag.toQTagArray() = QEventTag.assemble(eventId, relay, author) + +fun ATag.toQTagArray() = QAddressableTag.assemble(kind, pubKeyHex, dTag, relay) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QAddressableTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QAddressableTag.kt index 77008816d..b25b6ebfa 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QAddressableTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QAddressableTag.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip18Reposts.quotes import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory @@ -33,11 +35,11 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes data class QAddressableTag( val address: Address, ) : QTag { - var relay: String? = null + var relay: NormalizedRelayUrl? = null constructor( address: Address, - relayHint: String?, + relayHint: NormalizedRelayUrl?, ) : this(address) { this.relay = relayHint } @@ -46,7 +48,7 @@ data class QAddressableTag( kind: Int, pubKeyHex: HexKey, dTag: String, - relayHint: String?, + relayHint: NormalizedRelayUrl?, ) : this(Address(kind, pubKeyHex, dTag)) { this.relay = relayHint } @@ -54,7 +56,7 @@ data class QAddressableTag( fun countMemory(): Long = 2 * pointerSizeInBytes + address.countMemory() + - (relay?.bytesUsedInMemory() ?: 0) + (relay?.url?.bytesUsedInMemory() ?: 0) override fun toTagArray() = assemble(address, relay) @@ -67,7 +69,8 @@ data class QAddressableTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length != 64) { return null } val address = Address.parse(tag[1]) ?: return null - return QAddressableTag(address, tag.getOrNull(2)) + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + return QAddressableTag(address, hint) } @JvmStatic @@ -75,13 +78,13 @@ data class QAddressableTag( kind: Int, pubKeyHex: HexKey, dTag: String, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, Address.assemble(kind, pubKeyHex, dTag), relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, Address.assemble(kind, pubKeyHex, dTag), relay?.url) @JvmStatic fun assemble( address: Address, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QEventTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QEventTag.kt index ed8a22da1..662c6f9b9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QEventTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QEventTag.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip18Reposts.quotes import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.ensure @@ -32,10 +34,10 @@ import com.vitorpamplona.quartz.utils.pointerSizeInBytes data class QEventTag( val eventId: HexKey, ) : QTag { - var relay: String? = null + var relay: NormalizedRelayUrl? = null var author: HexKey? = null - constructor(eventId: HexKey, relayHint: String? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { + constructor(eventId: HexKey, relayHint: NormalizedRelayUrl? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { this.relay = relayHint this.author = authorPubKeyHex } @@ -43,7 +45,7 @@ data class QEventTag( fun countMemory(): Long = 3 * pointerSizeInBytes + // 3 fields, 4 bytes each reference (32bit) eventId.bytesUsedInMemory() + - (relay?.bytesUsedInMemory() ?: 0) + + (relay?.url?.bytesUsedInMemory() ?: 0) + (author?.bytesUsedInMemory() ?: 0) override fun toTagArray() = assemble(eventId, relay, author) @@ -56,14 +58,17 @@ data class QEventTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return QEventTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return QEventTag(tag[1], hint, tag.getOrNull(3)) } @JvmStatic fun assemble( eventId: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, author: HexKey?, - ) = arrayOfNotNull(TAG_NAME, eventId, relay, author) + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, author) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt index 46bdcf639..f059e9a90 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip18Reposts.quotes import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.ensure @@ -36,11 +37,14 @@ interface QTag { fun parse(tag: Array): QTag? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + return if (tag[1].length == 64) { - QEventTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + QEventTag(tag[1], relayHint, tag.getOrNull(3)) } else { val address = Address.parse(tag[1]) ?: return null - QAddressableTag(address, tag.getOrNull(2)) + QAddressableTag(address, relayHint) } } @@ -53,21 +57,29 @@ interface QTag { @JvmStatic fun parseEventAsHint(tag: Array): EventIdHint? { - ensure(tag.has(1)) { return null } + ensure(tag.has(2)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return EventIdHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return EventIdHint(tag[1], relayHint) } @JvmStatic fun parseAddressAsHint(tag: Array): AddressHint? { - ensure(tag.has(1)) { return null } + ensure(tag.has(2)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length != 64) { return null } ensure(tag[2].isNotEmpty()) { return null } ensure(!tag[1].contains(':')) { return null } - return AddressHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt index c46b59499..dc9bb9d64 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/ATagExt.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.quartz.nip19Bech32 import android.util.Log +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.utils.Hex @@ -44,7 +46,10 @@ fun ATag.Companion.parseAtag( try { val parts = atag.split(":", limit = 3) Hex.decode(parts[1]) - ATag(parts[0].toInt(), parts[1], parts[2], relay) + + val relayHint = relay?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + ATag(parts[0].toInt(), parts[1], parts[2], relayHint) } catch (t: Throwable) { Log.w("ATag", "Error parsing A Tag: $atag: ${t.message}") null @@ -58,7 +63,7 @@ fun ATag.Companion.parseAtagUnckecked(atag: String): ATag? = null } -fun ATag.toNAddr(overrideRelay: String? = relay): String = NAddress.create(kind, pubKeyHex, dTag, overrideRelay ?: relay) +fun ATag.toNAddr(overrideRelay: NormalizedRelayUrl? = relay): String = NAddress.create(kind, pubKeyHex, dTag, overrideRelay ?: relay) fun ATag.Companion.parseNAddr(naddr: String) = NAddress.parse(naddr)?.let { result -> diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/EventExt.kt index 151fa16c4..8d96412d4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/EventExt.kt @@ -22,10 +22,11 @@ package com.vitorpamplona.quartz.nip19Bech32 import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent -fun Event.toNIP19(relayHint: String? = null): String = +fun Event.toNIP19(relayHint: NormalizedRelayUrl? = null): String = if (this is AddressableEvent) { ATag(kind, pubKey, dTag(), relayHint).toNAddr() } else { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvBuilderExt.kt index 8deadc46c..cf9525037 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/TlvBuilderExt.kt @@ -42,6 +42,11 @@ fun TlvBuilder.addStringIfNotNull( data: String?, ) = addStringIfNotNull(type.id, data) +fun TlvBuilder.addStringIfNotBlank( + type: TlvTypes, + data: String, +) = addStringIfNotBlank(type.id, data) + fun TlvBuilder.addHexIfNotNull( type: TlvTypes, data: HexKey?, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NAddress.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NAddress.kt index 52955108c..d16627a6c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NAddress.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NAddress.kt @@ -23,9 +23,11 @@ package com.vitorpamplona.quartz.nip19Bech32.entities import addHex import addInt import addString -import addStringIfNotNull +import addStringIfNotBlank import android.util.Log import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip19Bech32.TlvTypes import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes @@ -38,10 +40,12 @@ data class NAddress( val kind: Int, val author: String, val dTag: String, - val relay: List, + val relay: List, ) : Entity { fun aTag(): String = Address.assemble(kind, author, dTag) + fun address() = Address(kind, author, dTag) + companion object { fun parse(naddr: String): NAddress? { try { @@ -68,20 +72,37 @@ data class NAddress( val author = tlv.firstAsHex(TlvTypes.AUTHOR.id) ?: return null val kind = tlv.firstAsInt(TlvTypes.KIND.id) ?: return null - return NAddress(kind, author, d, relay) + return NAddress(kind, author, d, relay.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }) } fun create( kind: Int, pubKeyHex: String, dTag: String, - vararg relays: String?, + relay: NormalizedRelayUrl?, + ): String = + TlvBuilder() + .apply { + addString(TlvTypes.SPECIAL, dTag) + if (relay != null) { + addStringIfNotBlank(TlvTypes.RELAY, relay.url) + } + addHex(TlvTypes.AUTHOR, pubKeyHex) + addInt(TlvTypes.KIND, kind) + }.build() + .toNAddress() + + fun create( + kind: Int, + pubKeyHex: String, + dTag: String, + relays: List, ): String = TlvBuilder() .apply { addString(TlvTypes.SPECIAL, dTag) relays.forEach { - addStringIfNotNull(TlvTypes.RELAY, it) + addStringIfNotBlank(TlvTypes.RELAY, it.url) } addHex(TlvTypes.AUTHOR, pubKeyHex) addInt(TlvTypes.KIND, kind) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEvent.kt index 62114d1d8..308220c8f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NEvent.kt @@ -25,6 +25,8 @@ import addHexIfNotNull import addIntIfNotNull import addStringIfNotNull import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.TlvTypes import com.vitorpamplona.quartz.nip19Bech32.asStringList import com.vitorpamplona.quartz.nip19Bech32.firstAsHex @@ -35,7 +37,7 @@ import com.vitorpamplona.quartz.nip19Bech32.toNEvent @Immutable data class NEvent( val hex: String, - val relay: List, + val relay: List, val author: String?, val kind: Int?, ) : Entity { @@ -52,20 +54,42 @@ data class NEvent( if (hex.isBlank()) return null - return NEvent(hex, relay, author, kind) + return NEvent( + hex, + relay.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }, + author, + kind, + ) } fun create( idHex: String, author: String?, kind: Int?, - vararg relays: String?, + relay: NormalizedRelayUrl?, + ): String = + TlvBuilder() + .apply { + addHex(TlvTypes.SPECIAL, idHex) + if (relay != null) { + addStringIfNotNull(TlvTypes.RELAY, relay.url) + } + addHexIfNotNull(TlvTypes.AUTHOR, author) + addIntIfNotNull(TlvTypes.KIND, kind) + }.build() + .toNEvent() + + fun create( + idHex: String, + author: String?, + kind: Int?, + relays: List, ): String = TlvBuilder() .apply { addHex(TlvTypes.SPECIAL, idHex) relays.forEach { - addStringIfNotNull(TlvTypes.RELAY, it) + addStringIfNotNull(TlvTypes.RELAY, it.url) } addHexIfNotNull(TlvTypes.AUTHOR, author) addIntIfNotNull(TlvTypes.KIND, kind) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt index 4c8453995..55c84758f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NProfile.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip19Bech32.entities import addHex import addStringIfNotNull import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.TlvTypes import com.vitorpamplona.quartz.nip19Bech32.asStringList import com.vitorpamplona.quartz.nip19Bech32.firstAsHex @@ -33,7 +35,7 @@ import com.vitorpamplona.quartz.nip19Bech32.toNProfile @Immutable data class NProfile( val hex: String, - val relay: List, + val relay: List, ) : Entity { companion object { fun parse(bytes: ByteArray): NProfile? { @@ -46,18 +48,18 @@ data class NProfile( if (hex.isBlank()) return null - return NProfile(hex, relay) + return NProfile(hex, relay.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }) } fun create( authorPubKeyHex: String, - relays: List, + relays: List, ): String = TlvBuilder() .apply { addHex(TlvTypes.SPECIAL, authorPubKeyHex) relays.forEach { - addStringIfNotNull(TlvTypes.RELAY, it) + addStringIfNotNull(TlvTypes.RELAY, it.url) } }.build() .toNProfile() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt index c42239310..6f0bf2954 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt @@ -21,12 +21,16 @@ package com.vitorpamplona.quartz.nip19Bech32.entities import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 @Immutable data class NSec( val hex: String, ) : Entity { + fun toPubKeyHex() = Nip01.pubKeyCreate(hex.hexToByteArray()).toHexKey() + companion object { fun parse(bytes: ByteArray): NSec? { if (bytes.isEmpty()) return null diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt index a515d2676..b09881d57 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt @@ -55,6 +55,15 @@ class TlvBuilder { data: String?, ) = data?.let { addString(type, it) } + fun addStringIfNotBlank( + type: Byte, + data: String, + ) { + if (data.isNotBlank()) { + addString(type, data) + } + } + fun addHexIfNotNull( type: Byte, data: HexKey?, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip21UriScheme/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip21UriScheme/EventExt.kt index 54d7eb263..28cca61d3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip21UriScheme/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip21UriScheme/EventExt.kt @@ -22,8 +22,9 @@ package com.vitorpamplona.quartz.nip21UriScheme import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip19Bech32.toNIP19 -fun Event.toNostrUri(relayHint: String? = null): String = "nostr:${toNIP19(relayHint)}" +fun Event.toNostrUri(relayHint: NormalizedRelayUrl? = null): String = "nostr:${toNIP19(relayHint)}" fun EventHintBundle.toNostrUri(): String = "nostr:${toNEvent()}" diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt index ddc263e61..d871c5489 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/TagArrayBuilderExt.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip22Comments import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTags @@ -39,12 +40,12 @@ import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId fun TagArrayBuilder.rootAddress( addressId: String, - relayHint: String?, + relayHint: NormalizedRelayUrl?, ) = addUnique(RootAddressTag.assemble(addressId, relayHint)) fun TagArrayBuilder.rootEvent( eventId: String, - relayHint: String?, + relayHint: NormalizedRelayUrl?, pubkey: String?, ) = addUnique(RootEventTag.assemble(eventId, relayHint, pubkey)) @@ -60,17 +61,17 @@ fun TagArrayBuilder.rootKind(id: ExternalId) = addUnique(RootKindT fun TagArrayBuilder.rootAuthor( pubKey: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, ) = add(RootAuthorTag.assemble(pubKey, relay)) fun TagArrayBuilder.replyAddress( addressId: String, - relayHint: String?, + relayHint: NormalizedRelayUrl?, ) = addUnique(ReplyAddressTag.assemble(addressId, relayHint)) fun TagArrayBuilder.replyEvent( eventId: String, - relayHint: String?, + relayHint: NormalizedRelayUrl?, pubkey: String?, ) = addUnique(ReplyEventTag.assemble(eventId, relayHint, pubkey)) @@ -86,7 +87,7 @@ fun TagArrayBuilder.replyKind(id: ExternalId) = addUnique(ReplyKin fun TagArrayBuilder.replyAuthor( pubKey: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, ) = add(ReplyAuthorTag.assemble(pubKey, relay)) fun TagArrayBuilder.notify(list: List) = pTags(list) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAddressTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAddressTag.kt index 28d4a1827..4582ce173 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAddressTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAddressTag.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -32,7 +34,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable class ReplyAddressTag( val addressId: String, - val relay: String? = null, + val relay: NormalizedRelayUrl? = null, ) { fun toTagArray() = assemble(addressId, relay) @@ -47,7 +49,10 @@ class ReplyAddressTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ReplyAddressTag(tag[1], tag.getOrNull(2)) + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ReplyAddressTag(tag[1], relayHint) } @JvmStatic @@ -64,21 +69,25 @@ class ReplyAddressTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return AddressHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) } @JvmStatic fun assemble( addressId: HexKey, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, addressId, relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, addressId, relay?.url) @JvmStatic fun assemble( kind: Int, pubKey: String, dTag: String, - relay: String?, + relay: NormalizedRelayUrl?, ) = assemble(Address.assemble(kind, pubKey, dTag), relay) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAuthorTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAuthorTag.kt index 8d7c3749f..f006a731e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAuthorTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyAuthorTag.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -32,7 +34,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable data class ReplyAuthorTag( override val pubKey: HexKey, - override val relayHint: String? = null, + override val relayHint: NormalizedRelayUrl? = null, ) : PubKeyReferenceTag { fun toTagArray() = assemble(pubKey, relayHint) @@ -47,7 +49,10 @@ data class ReplyAuthorTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ReplyAuthorTag(tag[1], tag.getOrNull(2)) + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ReplyAuthorTag(tag[1], hint) } @JvmStatic @@ -64,13 +69,17 @@ data class ReplyAuthorTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return PubKeyHint(tag[1], tag[2]) + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) } @JvmStatic fun assemble( pubkey: HexKey, - relayHint: String?, - ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint) + relayHint: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyEventTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyEventTag.kt index 6d4e42de1..4f891256f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyEventTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/ReplyEventTag.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.arrayOfNotNull @@ -34,7 +36,7 @@ import com.vitorpamplona.quartz.utils.ensure class ReplyEventTag( val ref: EventReference, ) { - constructor(eventId: String, relayHint: String?, pubkey: String?) : this(EventReference(eventId, relayHint, pubkey)) + constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this(EventReference(eventId, pubkey, relayHint)) fun toTagArray() = assemble(ref) @@ -61,7 +63,7 @@ class ReplyEventTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ReplyEventTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + return ReplyEventTag(tag[1], tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }, tag.getOrNull(3)) } @JvmStatic @@ -87,15 +89,19 @@ class ReplyEventTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return EventIdHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return EventIdHint(tag[1], relayHint) } @JvmStatic fun assemble( eventId: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, pubkey: String?, - ) = arrayOfNotNull(TAG_NAME, eventId, relay, pubkey) + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, pubkey) @JvmStatic fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAddressTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAddressTag.kt index 17cd96731..9278c3e0c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAddressTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAddressTag.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -32,7 +34,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable class RootAddressTag( val addressId: String, - val relay: String? = null, + val relay: NormalizedRelayUrl? = null, ) { fun toTagArray() = assemble(addressId, relay) @@ -59,7 +61,10 @@ class RootAddressTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } - return RootAddressTag(tag[1], tag.getOrNull(2)) + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return RootAddressTag(tag[1], relayHint) } @JvmStatic @@ -84,21 +89,25 @@ class RootAddressTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return AddressHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) } @JvmStatic fun assemble( addressId: HexKey, - relay: String?, - ) = arrayOfNotNull(TAG_NAME, addressId, relay) + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, addressId, relay?.url) @JvmStatic fun assemble( kind: Int, pubKey: String, dTag: String, - relay: String?, + relay: NormalizedRelayUrl?, ) = assemble(Address.assemble(kind, pubKey, dTag), relay) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAuthorTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAuthorTag.kt index 469600c52..a4afdeda8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAuthorTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootAuthorTag.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.quartz.nip01Core.core.PUBKEY_LENGTH import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -33,7 +35,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable data class RootAuthorTag( override val pubKey: HexKey, - override val relayHint: String? = null, + override val relayHint: NormalizedRelayUrl? = null, ) : PubKeyReferenceTag { fun toTagArray() = assemble(pubKey, relayHint) @@ -46,7 +48,10 @@ data class RootAuthorTag( @JvmStatic fun parse(tag: Tag): ReplyAuthorTag? { if (tag.size < 2 || tag[0] != TAG_NAME || tag[1].length != PUBKEY_LENGTH) return null - return ReplyAuthorTag(tag[1], tag.getOrNull(2)) + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ReplyAuthorTag(tag[1], relayHint) } @JvmStatic @@ -54,7 +59,10 @@ data class RootAuthorTag( ensure(tag.size >= 2) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == PUBKEY_LENGTH) { return null } - return ReplyAuthorTag(tag[1], tag.getOrNull(2)) + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ReplyAuthorTag(tag[1], relayHint) } @JvmStatic @@ -69,13 +77,17 @@ data class RootAuthorTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return PubKeyHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return PubKeyHint(tag[1], relayHint) } @JvmStatic fun assemble( pubkey: HexKey, - relayHint: String?, - ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint) + relayHint: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootEventTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootEventTag.kt index 3ca2355cc..ce210e6e8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootEventTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip22Comments/tags/RootEventTag.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.arrayOfNotNull @@ -34,7 +36,7 @@ import com.vitorpamplona.quartz.utils.ensure class RootEventTag( val ref: EventReference, ) { - constructor(eventId: String, relayHint: String?, pubkey: String?) : this(EventReference(eventId, relayHint, pubkey)) + constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this(EventReference(eventId, pubkey, relayHint)) fun toTagArray() = assemble(ref) @@ -61,7 +63,10 @@ class RootEventTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return RootEventTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return RootEventTag(tag[1], relayHint, tag.getOrNull(3)) } @JvmStatic @@ -87,15 +92,19 @@ class RootEventTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - return EventIdHint(tag[1], tag[2]) + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return EventIdHint(tag[1], relayHint) } @JvmStatic fun assemble( eventId: HexKey, - relay: String?, + relay: NormalizedRelayUrl?, pubkey: String?, - ) = arrayOfNotNull(TAG_NAME, eventId, relay, pubkey) + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, pubkey) @JvmStatic fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt index 01f2270f8..e130211f1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @@ -66,7 +67,7 @@ class LongTextNoteEvent( override fun dTag() = tags.dTag() - override fun aTag(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) + override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint) override fun address() = Address(kind, pubKey, dTag()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt index aa6a78b8b..d7925bd07 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelCreateEvent.kt @@ -20,14 +20,19 @@ */ package com.vitorpamplona.quartz.nip28PublicChat.admin +import android.R.attr.data +import android.util.Log import androidx.compose.runtime.Immutable +import com.fasterxml.jackson.core.JsonParseException import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData +import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @@ -41,9 +46,16 @@ class ChannelCreateEvent( sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider { - override fun eventHints() = channelInfo().relays?.map { EventIdHint(id, it) } ?: emptyList() + override fun eventHints() = channelInfo().relays?.mapNotNull { EventIdHint(id, it) } ?: emptyList() - fun channelInfo() = ChannelData.parse(content) ?: ChannelData() + fun channelInfo(): ChannelDataNorm { + return try { + ChannelData.parse(content)?.normalize() ?: ChannelDataNorm() + } catch (e: JsonParseException) { + Log.e("ChannelCreateEvent", "Failure to parse ${this.toJson()}", e) + ChannelDataNorm() + } + } companion object { const val KIND = 40 @@ -52,13 +64,13 @@ class ChannelCreateEvent( name: String?, about: String?, picture: String?, - relays: List?, + relays: List?, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = build(ChannelData(name, about, picture, relays), createdAt, initializer) + ) = build(ChannelDataNorm(name, about, picture, relays), createdAt, initializer) fun build( - data: ChannelData, + data: ChannelDataNorm, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, data.toContent(), createdAt) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt index 13f7db101..456bca8bc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt @@ -20,16 +20,20 @@ */ package com.vitorpamplona.quartz.nip28PublicChat.admin +import android.util.Log import androidx.compose.runtime.Immutable +import com.fasterxml.jackson.core.JsonParseException import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip28PublicChat.base.BasePublicChatEvent import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData +import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm import com.vitorpamplona.quartz.nip28PublicChat.base.channel import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils @@ -44,9 +48,16 @@ class ChannelMetadataEvent( sig: HexKey, ) : BasePublicChatEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider { - override fun eventHints() = channelInfo().relays?.map { EventIdHint(id, it) } ?: emptyList() + override fun eventHints() = channelInfo().relays?.mapNotNull { EventIdHint(id, it) } ?: emptyList() - fun channelInfo() = ChannelData.parse(content) ?: ChannelData() + fun channelInfo(): ChannelDataNorm { + return try { + ChannelData.parse(content)?.normalize() ?: ChannelDataNorm() + } catch (e: JsonParseException) { + Log.e("ChannelCreateEvent", "Failure to parse ${this.toJson()}", e) + ChannelDataNorm() + } + } companion object { const val KIND = 41 @@ -56,24 +67,24 @@ class ChannelMetadataEvent( name: String?, about: String?, picture: String?, - relays: List?, + relays: List?, channel: EventHintBundle, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = build(ChannelData(name, about, picture, relays), channel, createdAt, initializer) + ) = build(ChannelDataNorm(name, about, picture, relays), channel, createdAt, initializer) fun build( name: String?, about: String?, picture: String?, - relays: List?, + relays: List?, channel: ETag, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = build(ChannelData(name, about, picture, relays), channel, createdAt, initializer) + ) = build(ChannelDataNorm(name, about, picture, relays), channel, createdAt, initializer) fun build( - data: ChannelData, + data: ChannelDataNorm, channel: EventHintBundle, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, @@ -84,7 +95,7 @@ class ChannelMetadataEvent( } fun build( - data: ChannelData, + data: ChannelDataNorm, channel: ETag, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/ChannelData.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/ChannelData.kt index 088fed6f6..bdd0c2a0b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/ChannelData.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/base/ChannelData.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.quartz.nip28PublicChat.base -import android.util.Log import androidx.compose.runtime.Immutable import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.nip01Core.jackson.EventMapper +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @Immutable data class ChannelData( @@ -34,15 +35,22 @@ data class ChannelData( ) { fun toContent() = assemble(this) + fun normalize() = ChannelDataNorm(name, about, picture, relays?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }) + companion object { - fun parse(content: String): ChannelData? = - try { - EventMapper.mapper.readValue(content) - } catch (e: Exception) { - Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) - ChannelData(null, null, null, null) - } + fun parse(content: String): ChannelData? = EventMapper.mapper.readValue(content) fun assemble(data: ChannelData) = EventMapper.mapper.writeValueAsString(data) } } + +data class ChannelDataNorm( + val name: String? = null, + val about: String? = null, + val picture: String? = null, + val relays: List? = null, +) { + fun denormalize() = ChannelData(name, about, picture, relays?.mapNotNull { it.url }) + + fun toContent() = denormalize().toContent() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt index 727ee1a0c..e2dbde88b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHintOptional import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @@ -33,6 +34,7 @@ import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayBuilder import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.map @Immutable class ChannelListEvent( @@ -99,7 +101,7 @@ class ChannelListEvent( ) fun createChannel( - channel: EventIdHint, + channel: EventIdHintOptional, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), @@ -113,7 +115,7 @@ class ChannelListEvent( ) fun createChannels( - channels: List, + channels: List, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), @@ -178,7 +180,7 @@ class ChannelListEvent( fun addChannel( earlierVersion: ChannelListEvent, - channel: EventIdHint, + channel: EventIdHintOptional, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), @@ -194,7 +196,7 @@ class ChannelListEvent( fun addChannels( earlierVersion: ChannelListEvent, - channels: List, + channels: List, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayBuilderExt.kt index b297209a5..2c026c622 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayBuilderExt.kt @@ -22,9 +22,10 @@ package com.vitorpamplona.quartz.nip28PublicChat.list import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.events.ETag fun TagArrayBuilder.followChat( eventId: HexKey, - relayUrl: String, + relayUrl: NormalizedRelayUrl, ) = addUnique(ETag.assemble(eventId, relayUrl, null)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt index 7cc031fb5..6b761c514 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt @@ -27,7 +27,6 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent.Companion.ALT_DESCRIPTION import com.vitorpamplona.quartz.nip34Git.repository.tags.CloneTag import com.vitorpamplona.quartz.nip34Git.repository.tags.DescriptionTag import com.vitorpamplona.quartz.nip34Git.repository.tags.NameTag @@ -54,7 +53,7 @@ class GitRepositoryEvent( companion object { const val KIND = 30617 - const val ALT = "Git Repository" + const val ALT_DESCRIPTION = "Git Repository" fun build( name: String, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningTag.kt index 5e451e384..47c1a15c6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/ContentWarningTag.kt @@ -20,26 +20,30 @@ */ package com.vitorpamplona.quartz.nip36SensitiveContent -import com.vitorpamplona.quartz.utils.bytesUsedInMemory -import com.vitorpamplona.quartz.utils.pointerSizeInBytes +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.arrayOfNotNull class ContentWarningTag( - val reason: String, + val reason: String? = null, ) { - fun countMemory(): Long = 1 * pointerSizeInBytes + reason.bytesUsedInMemory() - fun toTagArray() = assemble(reason) companion object { const val TAG_NAME = "content-warning" + @JvmStatic + fun isTag(tag: Array) = tag.has(0) && tag[0] == TAG_NAME + @JvmStatic fun parse(tags: Array): ContentWarningTag { require(tags[0] == TAG_NAME) - return ContentWarningTag(tags[1]) + return ContentWarningTag(tags.getOrNull(1)) } @JvmStatic - fun assemble(reason: String) = arrayOf(TAG_NAME, reason) + fun assemble() = arrayOfNotNull(TAG_NAME) + + @JvmStatic + fun assemble(reason: String?) = arrayOfNotNull(TAG_NAME, reason) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayBuilderExt.kt index 369863734..fa44f876b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayBuilderExt.kt @@ -23,4 +23,6 @@ package com.vitorpamplona.quartz.nip36SensitiveContent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +fun TagArrayBuilder.contentWarning() = add(ContentWarningTag.assemble()) + fun TagArrayBuilder.contentWarning(reason: String) = add(ContentWarningTag.assemble(reason)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayExt.kt index f9b1be1a6..e087a6aa8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip36SensitiveContent/TagArrayExt.kt @@ -21,11 +21,10 @@ package com.vitorpamplona.quartz.nip36SensitiveContent import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag -fun TagArray.isSensitive() = this.any { (it.size > 0 && it[0] == ContentWarningTag.TAG_NAME) } +val nsfwTags = setOf("nsfw", "nude", "NSFW", "NUDE", "Nsfw", "Nude") -fun TagArray.isSensitiveOrNSFW() = - this.any { - (it.size > 0 && it[0] == ContentWarningTag.TAG_NAME) || - (it.size > 1 && it[0] == "t" && (it[1].equals("nsfw", true) || it[1].equals("nude", true))) - } +fun TagArray.isSensitive() = this.any(ContentWarningTag::isTag) + +fun TagArray.isSensitiveOrNSFW() = this.any { ContentWarningTag.isTag(it) || HashtagTag.isAnyTagged(it, nsfwTags) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt index d10fdc0bf..5e2d0dade 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt @@ -26,9 +26,18 @@ import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag.Companion.parseAsHint import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseAsHint import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag.Companion.parseAsHint import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent @@ -45,7 +54,16 @@ class DraftEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + @Transient private var cachedInnerEvent: Map = mapOf() override fun countMemory(): Long = diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/EventExt.kt index a253366fe..beb40d9cc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/EventExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/EventExt.kt @@ -21,15 +21,9 @@ package com.vitorpamplona.quartz.nip40Expiration import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.utils.TimeUtils -fun Event.expiration() = - try { - tags.firstOrNull { it.size > 1 && it[0] == "expiration" }?.get(1)?.toLongOrNull() - } catch (_: Exception) { - null - } +fun Event.expiration() = tags.expiration() -fun Event.isExpired() = (expiration() ?: Long.MAX_VALUE) < TimeUtils.now() +fun Event.isExpired() = tags.isExpired() -fun Event.isExpirationBefore(time: Long) = (expiration() ?: Long.MAX_VALUE) < time +fun Event.isExpirationBefore(time: Long) = tags.isExpirationBefore(time) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/ExpirationTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/ExpirationTag.kt new file mode 100644 index 000000000..7e178f451 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/ExpirationTag.kt @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2025 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.quartz.nip40Expiration + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +class ExpirationTag { + companion object { + const val TAG_NAME = "expiration" + + @JvmStatic + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun parse(tag: Array): Long? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toLongOrNull() + } + + @JvmStatic + fun assemble(time: Long) = arrayOfNotNull(TAG_NAME, time.toString()) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayBuilderExt.kt new file mode 100644 index 000000000..2835041a8 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2025 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.quartz.nip40Expiration + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.expiration(time: Long) = addUnique(ExpirationTag.assemble(time)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayExt.kt new file mode 100644 index 000000000..2ddaa8aa3 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip40Expiration/TagArrayExt.kt @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2025 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.quartz.nip40Expiration + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.utils.TimeUtils + +fun TagArray.expiration() = this.firstNotNullOfOrNull(ExpirationTag::parse) + +fun TagArray.isExpired(): Boolean { + val exp = expiration() ?: return false + return exp < TimeUtils.now() +} + +fun TagArray.isExpirationBefore(time: Long): Boolean { + val exp = expiration() ?: return false + return exp < time +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt index e83a8bc1b..83d8a1797 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt @@ -23,7 +23,10 @@ package com.vitorpamplona.quartz.nip42RelayAuth import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip42RelayAuth.tags.ChallengeTag +import com.vitorpamplona.quartz.nip42RelayAuth.tags.RelayTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -35,15 +38,15 @@ class RelayAuthEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relay() = tags.firstOrNull { it.size > 1 && it[0] == "relay" }?.get(1) + fun relay() = tags.firstNotNullOfOrNull(RelayTag::parse) - fun challenge() = tags.firstOrNull { it.size > 1 && it[0] == "challenge" }?.get(1) + fun challenge() = tags.firstNotNullOfOrNull(ChallengeTag::parse) companion object { const val KIND = 22242 fun create( - relay: String, + relay: NormalizedRelayUrl, challenge: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), @@ -52,14 +55,14 @@ class RelayAuthEvent( val content = "" val tags = arrayOf( - arrayOf("relay", relay), - arrayOf("challenge", challenge), + RelayTag.assemble(relay), + ChallengeTag.assemble(challenge), ) signer.sign(createdAt, KIND, tags, content, onReady) } fun create( - relays: List, + relays: List, challenge: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), @@ -68,11 +71,9 @@ class RelayAuthEvent( val content = "" val tags = relays - .map { - arrayOf("relay", it) - }.plusElement( - arrayOf("challenge", challenge), - ).toTypedArray() + .map { RelayTag.assemble(it) } + .plusElement(ChallengeTag.assemble(challenge)) + .toTypedArray() signer.sign(createdAt, KIND, tags, content, onReady) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/ChallengeTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/ChallengeTag.kt new file mode 100644 index 000000000..2cf0d5fae --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/ChallengeTag.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2025 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.quartz.nip42RelayAuth.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ChallengeTag { + companion object { + const val TAG_NAME = "challenge" + + @JvmStatic + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + @JvmStatic + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/RelayTag.kt new file mode 100644 index 000000000..bce30729f --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip42RelayAuth/tags/RelayTag.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 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.quartz.nip42RelayAuth.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +class RelayTag { + companion object { + const val TAG_NAME = "relay" + + @JvmStatic + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun notMatch(tag: Array) = tag.has(0) && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): NormalizedRelayUrl? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) ?: return null + + return relay + } + + @JvmStatic + fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt index 2525ca7dc..ef4bdbe98 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt @@ -23,13 +23,15 @@ package com.vitorpamplona.quartz.nip47WalletConnect import androidx.core.net.toUri import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey import kotlinx.coroutines.CancellationException // Rename to the corect nip number when ready. class Nip47WalletConnect { companion object { - fun parse(uri: String): Nip47URI { + fun parse(uri: String): Nip47URINorm { // nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D val url = uri.toUri() @@ -49,9 +51,10 @@ class Nip47WalletConnect { } val relay = url.getQueryParameter("relay") ?: throw IllegalArgumentException("Relay cannot be null") + val relayNorm = RelayUrlNormalizer.normalizeOrNull(relay) ?: throw IllegalArgumentException("Invalid relay Url") val secret = url.getQueryParameter("secret") - return Nip47URI(pubkeyHex, relay, secret) + return Nip47URINorm(pubkeyHex, relayNorm, secret) } } @@ -59,5 +62,20 @@ class Nip47WalletConnect { val pubKeyHex: HexKey, val relayUri: String, val secret: HexKey?, + ) { + fun normalize(): Nip47WalletConnect.Nip47URINorm? = + RelayUrlNormalizer.normalizeOrNull(relayUri)?.let { + Nip47URINorm( + pubKeyHex, + it, + secret, + ) + } + } + + data class Nip47URINorm( + val pubKeyHex: HexKey, + val relayUri: NormalizedRelayUrl, + val secret: HexKey?, ) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt index b43cdf53f..8ef77d9f4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt @@ -23,11 +23,13 @@ package com.vitorpamplona.quartz.nip50Search import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip50Search.tags.RelayTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -39,14 +41,7 @@ class SearchRelayListEvent( content: String, sig: HexKey, ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relays(): List = - tags.mapNotNull { - if (it.size > 1 && it[0] == "relay") { - it[1] - } else { - null - } - } + fun relays(): List = tags.mapNotNull(RelayTag::parse) companion object { const val KIND = 10007 @@ -57,26 +52,26 @@ class SearchRelayListEvent( fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) - fun createTagArray(relays: List): Array> = + fun createTagArray(relays: List): Array> = relays .map { - arrayOf("relay", it) + RelayTag.assemble(it) }.plusElement(AltTag.assemble("Relay list to use for Search")) .toTypedArray() fun updateRelayList( earlierVersion: SearchRelayListEvent, - relays: List, + relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (SearchRelayListEvent) -> Unit, ) { val tags = earlierVersion.tags - .filter { it[0] != "relay" } + .filter(RelayTag::notMatch) .plus( relays.map { - arrayOf("relay", it) + RelayTag.assemble(it) }, ).toTypedArray() @@ -84,7 +79,7 @@ class SearchRelayListEvent( } fun createFromScratch( - relays: List, + relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (SearchRelayListEvent) -> Unit, @@ -93,7 +88,7 @@ class SearchRelayListEvent( } fun create( - relays: List, + relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (SearchRelayListEvent) -> Unit, @@ -102,7 +97,7 @@ class SearchRelayListEvent( } fun create( - relays: List, + relays: List, signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), ): SearchRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "") diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/tags/RelayTag.kt new file mode 100644 index 000000000..db8babc10 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip50Search/tags/RelayTag.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 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.quartz.nip50Search.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +class RelayTag { + companion object { + const val TAG_NAME = "relay" + + @JvmStatic + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun notMatch(tag: Array) = tag.has(0) && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): NormalizedRelayUrl? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) ?: return null + + return relay + } + + @JvmStatic + fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/MuteListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/MuteListEvent.kt index fb56d48f8..4536f28bd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/MuteListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/MuteListEvent.kt @@ -25,7 +25,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent.UsersAndWords import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.tryAndWait +import kotlin.coroutines.resume @Immutable class MuteListEvent( @@ -38,17 +41,34 @@ class MuteListEvent( ) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { override fun dTag() = FIXED_D_TAG + fun publicUsersAndWords() = + PeopleListEvent.UsersAndWords( + filterTagList("p", tags), + filterTagList("word", tags), + ) + fun publicAndPrivateUsersAndWords( signer: NostrSigner, onReady: (PeopleListEvent.UsersAndWords) -> Unit, ) { privateTagsOrEmpty(signer) { onReady( - PeopleListEvent.UsersAndWords(filterTagList("p", it), filterTagList("word", it)), + PeopleListEvent.UsersAndWords( + filterTagList("p", it), + filterTagList("word", it), + ), ) } } + suspend fun publicAndPrivateUsersAndWords(signer: NostrSigner): UsersAndWords? { + return tryAndWait { continuation -> + publicAndPrivateUsersAndWords(signer) { privateTagList -> + continuation.resume(privateTagList) + } + } + } + companion object { const val KIND = 10000 const val FIXED_D_TAG = "" diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PeopleListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PeopleListEvent.kt index 568c7fbdd..3e35ff9f5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PeopleListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/PeopleListEvent.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.tryAndWait +import kotlin.coroutines.resume @Immutable class PeopleListEvent( @@ -42,6 +44,12 @@ class PeopleListEvent( val words: Set = setOf(), ) + fun publicUsersAndWords() = + UsersAndWords( + filterTagList("p", tags), + filterTagList("word", tags), + ) + fun publicAndPrivateUsersAndWords( signer: NostrSigner, onReady: (UsersAndWords) -> Unit, @@ -56,6 +64,14 @@ class PeopleListEvent( } } + suspend fun publicAndPrivateUsersAndWords(signer: NostrSigner): UsersAndWords? { + return tryAndWait { continuation -> + publicAndPrivateUsersAndWords(signer) { privateTagList -> + continuation.resume(privateTagList) + } + } + } + fun isTaggedWord( word: String, isPrivate: Boolean, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/RelaySetEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/RelaySetEvent.kt index b43207bf4..4a2111680 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/RelaySetEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/RelaySetEvent.kt @@ -23,8 +23,10 @@ package com.vitorpamplona.quartz.nip51Lists import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip51Lists.tags.RelayTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -36,7 +38,7 @@ class RelaySetEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relays() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] } + fun relays(): List = tags.mapNotNull(RelayTag::parse) fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/interests/HashtagListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/interests/HashtagListEvent.kt new file mode 100644 index 000000000..d56059e18 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/interests/HashtagListEvent.kt @@ -0,0 +1,218 @@ +/** + * Copyright (c) 2025 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.quartz.nip51Lists.interests + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.tryAndWait +import kotlin.coroutines.resume + +@Immutable +class HashtagListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient var publicAndPrivateHashtagCache: Set? = null + + fun publicHashtags() = tags.mapNotNull(HashtagTag::parse) + + fun publicAndPrivateHashtag( + signer: NostrSigner, + onReady: (Set) -> Unit, + ) { + publicAndPrivateHashtagCache?.let { eventList -> + onReady(eventList) + return + } + + mergeTagList(signer) { + val set = it.mapNotNull(HashtagTag::parse).toSet() + publicAndPrivateHashtagCache = set + onReady(set) + } + } + + suspend fun publicAndPrivateHashtag(signer: NostrSigner): Set? { + publicAndPrivateHashtagCache?.let { return it } + + return tryAndWait { continuation -> + publicAndPrivateHashtag(signer) { privateTagList -> + continuation.resume(privateTagList) + } + } + } + + companion object { + const val KIND = 10015 + const val ALT = "Hashtag List" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + private fun createHashtagBase( + tags: Array>, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (HashtagListEvent) -> Unit, + ) { + PrivateTagArrayBuilder.create( + tags, + isPrivate, + signer, + ) { encryptedContent, newTags -> + create(encryptedContent, newTags, signer, createdAt, onReady) + } + } + + fun createHashtag( + hashtag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (HashtagListEvent) -> Unit, + ) = createHashtagBase( + tags = arrayOf(HashtagTag.assemble(hashtag)), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + + fun createHashtags( + hashtags: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (HashtagListEvent) -> Unit, + ) = createHashtagBase( + tags = HashtagTag.assemble(hashtags).toTypedArray(), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + + fun removeHashtag( + earlierVersion: HashtagListEvent, + hashtag: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (HashtagListEvent) -> Unit, + ) { + PrivateTagArrayBuilder.removeAll( + earlierVersion, + HashtagTag.assemble(hashtag), + signer, + ) { encryptedContent, newTags -> + create(encryptedContent, newTags, signer, createdAt, onReady) + } + } + + private fun addHashtagBase( + earlierVersion: HashtagListEvent, + newTags: Array>, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (HashtagListEvent) -> Unit, + ) { + PrivateTagArrayBuilder.addAll( + earlierVersion, + newTags, + isPrivate, + signer, + ) { encryptedContent, newTags -> + create(encryptedContent, newTags, signer, createdAt, onReady) + } + } + + fun addHashtag( + earlierVersion: HashtagListEvent, + hashtag: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (HashtagListEvent) -> Unit, + ) = addHashtagBase( + earlierVersion, + arrayOf(HashtagTag.assemble(hashtag)), + isPrivate, + signer, + createdAt, + onReady, + ) + + fun addHashtags( + earlierVersion: HashtagListEvent, + hashtags: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (HashtagListEvent) -> Unit, + ) = addHashtagBase( + earlierVersion, + HashtagTag.assemble(hashtags).toTypedArray(), + isPrivate, + signer, + createdAt, + onReady, + ) + + private fun create( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (HashtagListEvent) -> Unit, + ) { + val newTags = + if (tags.any { it.size > 1 && it[0] == "alt" }) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + signer.sign(createdAt, KIND, newTags, content, onReady) + } + + fun create( + hashtags: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): HashtagListEvent? { + val tags = HashtagTag.assemble(hashtags).toTypedArray() + return signer.sign(createdAt, KIND, tags, "") + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/interests/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/interests/TagArrayBuilderExt.kt new file mode 100644 index 000000000..4fca44c0e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/interests/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2025 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.quartz.nip51Lists.interests + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag + +fun TagArrayBuilder.followHashTag(hashtag: String) = add(HashtagTag.assemble(hashtag)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/locations/GeohashListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/locations/GeohashListEvent.kt new file mode 100644 index 000000000..dff171129 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/locations/GeohashListEvent.kt @@ -0,0 +1,219 @@ +/** + * Copyright (c) 2025 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.quartz.nip51Lists.locations + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag.Companion.parse +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.tryAndWait +import kotlin.coroutines.resume + +@Immutable +class GeohashListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient var publicAndPrivateGeohashCache: Set? = null + + fun publicGeohashes() = tags.mapNotNull(GeoHashTag::parse) + + fun publicAndPrivateGeohash( + signer: NostrSigner, + onReady: (Set) -> Unit, + ) { + publicAndPrivateGeohashCache?.let { eventList -> + onReady(eventList) + return + } + + mergeTagList(signer) { + val set = it.mapNotNull(GeoHashTag::parse).toSet() + publicAndPrivateGeohashCache = set + onReady(set) + } + } + + suspend fun publicAndPrivateGeohash(signer: NostrSigner): Set? { + publicAndPrivateGeohashCache?.let { return it } + + return tryAndWait { continuation -> + publicAndPrivateGeohash(signer) { privateTagList -> + continuation.resume(privateTagList) + } + } + } + + companion object { + const val KIND = 10081 + const val ALT = "Geohash List" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + private fun createGeohashBase( + tags: Array>, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GeohashListEvent) -> Unit, + ) { + PrivateTagArrayBuilder.create( + tags, + isPrivate, + signer, + ) { encryptedContent, newTags -> + create(encryptedContent, newTags, signer, createdAt, onReady) + } + } + + fun createGeohash( + geohash: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GeohashListEvent) -> Unit, + ) = createGeohashBase( + tags = arrayOf(GeoHashTag.assembleSingle(geohash)), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + + fun createGeohashs( + geohashs: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GeohashListEvent) -> Unit, + ) = createGeohashBase( + tags = geohashs.map { GeoHashTag.assembleSingle(it) }.toTypedArray(), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + + fun removeGeohash( + earlierVersion: GeohashListEvent, + geohash: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GeohashListEvent) -> Unit, + ) { + PrivateTagArrayBuilder.removeAll( + earlierVersion, + GeoHashTag.assembleSingle(geohash), + signer, + ) { encryptedContent, newTags -> + create(encryptedContent, newTags, signer, createdAt, onReady) + } + } + + private fun addGeohashBase( + earlierVersion: GeohashListEvent, + newTags: Array>, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GeohashListEvent) -> Unit, + ) { + PrivateTagArrayBuilder.addAll( + earlierVersion, + newTags, + isPrivate, + signer, + ) { encryptedContent, newTags -> + create(encryptedContent, newTags, signer, createdAt, onReady) + } + } + + fun addGeohash( + earlierVersion: GeohashListEvent, + geohash: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GeohashListEvent) -> Unit, + ) = addGeohashBase( + earlierVersion, + arrayOf(GeoHashTag.assembleSingle(geohash)), + isPrivate, + signer, + createdAt, + onReady, + ) + + fun addGeohashs( + earlierVersion: GeohashListEvent, + geohashs: List, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GeohashListEvent) -> Unit, + ) = addGeohashBase( + earlierVersion, + geohashs.map { GeoHashTag.assembleSingle(it) }.toTypedArray(), + isPrivate, + signer, + createdAt, + onReady, + ) + + private fun create( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (GeohashListEvent) -> Unit, + ) { + val newTags = + if (tags.any { it.size > 1 && it[0] == "alt" }) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + signer.sign(createdAt, KIND, newTags, content, onReady) + } + + fun create( + geohashs: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): GeohashListEvent? { + val tags = geohashs.map { GeoHashTag.assembleSingle(it) }.toTypedArray() + return signer.sign(createdAt, KIND, tags, "") + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/locations/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/locations/TagArrayBuilderExt.kt new file mode 100644 index 000000000..e91af0ce2 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/locations/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2025 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.quartz.nip51Lists.locations + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag + +fun TagArrayBuilder.followGeohash(geohash: String) = add(GeoHashTag.assembleSingle(geohash)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/RelayTag.kt new file mode 100644 index 000000000..50b700f64 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/tags/RelayTag.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 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.quartz.nip51Lists.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +class RelayTag { + companion object { + const val TAG_NAME = "r" + + @JvmStatic + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun notMatch(tag: Array) = tag.has(0) && tag[0] == TAG_NAME + + @JvmStatic + fun parse(tag: Array): NormalizedRelayUrl? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) ?: return null + + return relay + } + + @JvmStatic + fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt index e72556a15..df9a1dc4c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt @@ -24,7 +24,18 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag.Companion.parseAddressAsHint +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag.Companion.parseEventAsHint import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag @@ -38,6 +49,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StreamingTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.TotalParticipantsTag import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.flatten @Immutable class LiveActivitiesEvent( @@ -47,7 +59,16 @@ class LiveActivitiesEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) + + override fun pubKeyHints() = tags.mapNotNull(ParticipantTag::parseAsHint) + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) @@ -70,7 +91,7 @@ class LiveActivitiesEvent( fun relays() = tags.mapNotNull(RelayListTag::parse) - fun allRelayUrls() = tags.mapNotNull(RelayListTag::parse).map { it.relayUrls }.flatten() + fun allRelayUrls() = tags.mapNotNull(RelayListTag::parse).flatten() fun hasHost() = tags.any(ParticipantTag::isHost) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt index 1c2733fbe..5478fedf1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt @@ -24,6 +24,9 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -38,7 +41,7 @@ enum class ROLE( @Immutable data class ParticipantTag( override val pubKey: String, - override val relayHint: String?, + override val relayHint: NormalizedRelayUrl?, val role: String?, val proof: String?, ) : PubKeyReferenceTag { @@ -64,7 +67,10 @@ data class ParticipantTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ParticipantTag(tag[1], tag.getOrNull(2), tag.getOrNull(3), tag.getOrNull(4)) + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ParticipantTag(tag[1], hint, tag.getOrNull(3), tag.getOrNull(4)) } @JvmStatic @@ -73,7 +79,10 @@ data class ParticipantTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } ensure(tag[3] == ROLE.HOST.code) { return null } - return ParticipantTag(tag[1], tag[2], tag[3], tag.getOrNull(4)) + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + + return ParticipantTag(tag[1], hint, tag[3], tag.getOrNull(4)) } @JvmStatic @@ -84,6 +93,19 @@ data class ParticipantTag( return tag[1] } + @JvmStatic + fun parseAsHint(tag: Array): PubKeyHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) + } + @JvmStatic fun assemble( pubkey: HexKey, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/RelayListTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/RelayListTag.kt index ac2597048..e158c766f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/RelayListTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/RelayListTag.kt @@ -21,30 +21,30 @@ package com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.ensure -class RelayListTag( - val relayUrls: List, -) { +class RelayListTag { companion object { const val TAG_NAME = "relays" @JvmStatic - fun parse(tag: Array): RelayListTag? { + fun parse(tag: Array): List? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } val relays = tag.mapIndexedNotNull { index, s -> - if (index == 0) null else s + if (index == 0) null else RelayUrlNormalizer.normalizeOrNull(s) } - return RelayListTag(relays) + + if (relays.isEmpty()) return null + + return relays } @JvmStatic - fun assemble(urls: List) = arrayOf(TAG_NAME) + urls.toTypedArray() - - @JvmStatic - fun assemble(tag: RelayListTag) = assemble(tag.relayUrls) + fun assemble(urls: List) = arrayOf(TAG_NAME) + urls.map { it.url }.toTypedArray() } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt index 057fd9b2d..823edee0d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip54Wiki import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address @@ -44,7 +45,7 @@ class WikiNoteEvent( AddressableEvent { override fun dTag() = tags.dTag() - override fun aTag(relayHint: String?) = ATag(kind, pubKey, dTag(), relayHint) + override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint) override fun address() = Address(kind, pubKey, dTag()) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/NostrSignerExternal.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/NostrSignerExternal.kt index e294e32f8..2b7d9ad68 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/NostrSignerExternal.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip55AndroidSigner/NostrSignerExternal.kt @@ -33,6 +33,8 @@ class NostrSignerExternal( pubKey: HexKey, val launcher: ExternalSignerLauncher, ) : NostrSigner(pubKey) { + override fun isWriteable(): Boolean = true + override fun sign( createdAt: Long, kind: Int, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt index 099ea723a..c6876c9bb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip56Reports/ReportEvent.kt @@ -24,8 +24,17 @@ import androidx.compose.runtime.Immutable 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.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseAsHint import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip56Reports.tags.DefaultReportTag @@ -43,7 +52,16 @@ class ReportEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + @Transient private var defaultType: ReportType? = null diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt index 21403f517..28c669c91 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt @@ -20,12 +20,22 @@ */ package com.vitorpamplona.quartz.nip57Zaps +import android.R.attr.description import android.util.Log import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseAsHint import com.vitorpamplona.quartz.utils.pointerSizeInBytes @Immutable @@ -37,7 +47,16 @@ class LnZapEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), - LnZapEventInterface { + LnZapEventInterface, + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + // This event is also kept in LocalCache (same object) @Transient val zapRequest: LnZapRequestEvent? diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt index 60aaae691..071f95c14 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt @@ -23,8 +23,17 @@ package com.vitorpamplona.quartz.nip57Zaps import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseAsHint import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -35,7 +44,16 @@ class LnZapPrivateEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + companion object { const val KIND = 9733 const val ALT = "Private zap" diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt index 2f4dcd910..8df671132 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt @@ -27,8 +27,13 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip31Alts.AltTag @@ -43,7 +48,16 @@ class LnZapRequestEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + @Transient private var privateZapEvent: LnZapPrivateEvent? = null override fun countMemory(): Long = super.countMemory() + pointerSizeInBytes + (privateZapEvent?.countMemory() ?: 0) @@ -138,7 +152,7 @@ class LnZapRequestEvent( fun create( userHex: String, - relays: Set, + relays: Set, signer: NostrSigner, message: String, zapType: LnZapEvent.ZapType, @@ -150,7 +164,7 @@ class LnZapRequestEvent( var tags = arrayOf( arrayOf("p", userHex), - arrayOf("relays") + relays, + arrayOf("relays") + relays.map { it.url }, ) if (zapType == LnZapEvent.ZapType.ANONYMOUS) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetup.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetup.kt index dd33eb64f..1f48c9a10 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetup.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetup.kt @@ -21,10 +21,11 @@ package com.vitorpamplona.quartz.nip57Zaps.splits import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl data class ZapSplitSetup( val pubKeyHex: HexKey, - val relay: String?, + val relay: NormalizedRelayUrl?, override val weight: Double, ) : BaseZapSplitSetup { override fun mainId() = pubKeyHex diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupParser.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupParser.kt index 4d12cca53..3924e101b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupParser.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupParser.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip57Zaps.splits import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.ensure class ZapSplitSetupParser { @@ -40,9 +41,11 @@ class ZapSplitSetupParser { if (isLnAddress) { ZapSplitSetupLnAddress(tags[1], 1.0) } else { + val relayHint = tags.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + ZapSplitSetup( tags[1], - tags.getOrNull(2), + relayHint, weight, ) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupSerializer.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupSerializer.kt index 81a93f247..dbef78241 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupSerializer.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/splits/ZapSplitSetupSerializer.kt @@ -20,13 +20,15 @@ */ package com.vitorpamplona.quartz.nip57Zaps.splits +import com.vitorpamplona.quartz.utils.arrayOfNotNull + class ZapSplitSetupSerializer { companion object { @JvmStatic fun toTagArray(zapSplit: BaseZapSplitSetup): Array = when (zapSplit) { is ZapSplitSetupLnAddress -> arrayOf(BaseZapSplitSetup.TAG_NAME, zapSplit.lnAddress) - is ZapSplitSetup -> arrayOf(BaseZapSplitSetup.TAG_NAME, zapSplit.pubKeyHex, zapSplit.relay ?: "", zapSplit.weight.toString()) + is ZapSplitSetup -> arrayOfNotNull(BaseZapSplitSetup.TAG_NAME, zapSplit.pubKeyHex, zapSplit.relay?.url, zapSplit.weight.toString()) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeAwardEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeAwardEvent.kt index 8ead7f1c2..be7996a23 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeAwardEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeAwardEvent.kt @@ -23,7 +23,16 @@ package com.vitorpamplona.quartz.nip58Badges import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag.Companion.parseAsHint import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseAsHint import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers @@ -35,7 +44,16 @@ class BadgeAwardEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + fun awardees() = taggedUsers() fun awardeeIds() = taggedUserIds() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeProfilesEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeProfilesEvent.kt index edd9ee695..9488881d9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeProfilesEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip58Badges/BadgeProfilesEvent.kt @@ -23,10 +23,17 @@ package com.vitorpamplona.quartz.nip58Badges import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parseAsHint import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseAsHint @Immutable class BadgeProfilesEvent( @@ -36,7 +43,16 @@ class BadgeProfilesEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + fun badgeAwardEvents() = taggedEvents() fun badgeAwardDefinitions() = taggedAddresses() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt index ab53f0a07..362758f78 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt @@ -27,8 +27,9 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address -import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.map @Immutable class AdvertisedRelayListEvent( @@ -39,48 +40,15 @@ class AdvertisedRelayListEvent( content: String, sig: HexKey, ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun relays(): List = - tags.mapNotNull { - if (it.size > 1 && it[0] == "r") { - val type = - when (it.getOrNull(2)) { - "read" -> AdvertisedRelayType.READ - "write" -> AdvertisedRelayType.WRITE - else -> AdvertisedRelayType.BOTH - } + fun relays() = tags.mapNotNull(AdvertisedRelayInfo::parse) - AdvertisedRelayInfo(it[1], type) - } else { - null - } - } + fun readRelays() = tags.mapNotNull(AdvertisedRelayInfo::parseRead).ifEmpty { null } - fun readRelays(): List? = - tags - .mapNotNull { - if (it.size > 1 && it[0] == "r") { - when (it.getOrNull(2)) { - "read" -> it[1] - "write" -> null - else -> it[1] - } - } else { - null - } - }.ifEmpty { null } + fun readRelaysNorm() = tags.mapNotNull(AdvertisedRelayInfo::parseReadNorm).ifEmpty { null } - fun writeRelays(): List = - tags.mapNotNull { - if (it.size > 1 && it[0] == "r") { - when (it.getOrNull(2)) { - "read" -> null - "write" -> it[1] - else -> it[1] - } - } else { - null - } - } + fun writeRelays() = tags.mapNotNull(AdvertisedRelayInfo::parseWrite).ifEmpty { null } + + fun writeRelaysNorm() = tags.mapNotNull(AdvertisedRelayInfo::parseWriteNorm).ifEmpty { null } companion object { const val KIND = 10002 @@ -92,21 +60,17 @@ class AdvertisedRelayListEvent( fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) - fun updateRelayList( + fun replaceRelayListWith( earlierVersion: AdvertisedRelayListEvent, - relays: List, + newRelays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (AdvertisedRelayListEvent) -> Unit, ) { - val tags = - earlierVersion.tags - .filter { it[0] != "r" } - .plus( - relays.map(Companion::createRelayTag), - ).toTypedArray() + val tags = earlierVersion.tags.filter(AdvertisedRelayInfo::notMatch) + val relayTags = newRelays.map { it.toTagArray() } - signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady) + signer.sign(createdAt, KIND, (tags + relayTags).toTypedArray(), earlierVersion.content, onReady) } fun createFromScratch( @@ -114,22 +78,7 @@ class AdvertisedRelayListEvent( signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (AdvertisedRelayListEvent) -> Unit, - ) { - create(relays, signer, createdAt, onReady) - } - - fun createRelayTag(relay: AdvertisedRelayInfo): Array = - if (relay.type == AdvertisedRelayType.BOTH) { - arrayOf("r", relay.relayUrl) - } else { - arrayOf("r", relay.relayUrl, relay.type.code) - } - - fun createTagArray(relays: List): Array> = - relays - .map(Companion::createRelayTag) - .plusElement(AltTag.assemble(ALT)) - .toTypedArray() + ) = create(relays, signer, createdAt, onReady) fun create( list: List, @@ -137,7 +86,7 @@ class AdvertisedRelayListEvent( createdAt: Long = TimeUtils.now(), onReady: (AdvertisedRelayListEvent) -> Unit, ) { - val tags = createTagArray(list) + val tags = list.map { it.toTagArray() }.toTypedArray() val msg = "" signer.sign(createdAt, KIND, tags, msg, onReady) @@ -148,24 +97,10 @@ class AdvertisedRelayListEvent( signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), ): AdvertisedRelayListEvent? { - val tags = createTagArray(list) + val tags = list.map { it.toTagArray() }.toTypedArray() val msg = "" return signer.sign(createdAt, KIND, tags, msg) } } - - @Immutable data class AdvertisedRelayInfo( - val relayUrl: String, - val type: AdvertisedRelayType, - ) - - @Immutable - enum class AdvertisedRelayType( - val code: String, - ) { - BOTH(""), - READ("read"), - WRITE("write"), - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt index 88be891cf..fd0953e56 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt @@ -21,29 +21,26 @@ package com.vitorpamplona.quartz.nip65RelayList import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion +import com.vitorpamplona.quartz.utils.mapOfSet class RelayListRecommendationProcessor { companion object { fun transpose( - userList: Map>, - ignore: Set = setOf(), - ): Map> { - val popularity = mutableMapOf>() - - userList.forEach { event -> - event.value.forEach { relayUrl -> - if (relayUrl !in ignore) { - val set = popularity[relayUrl] - if (set != null) { - set.add(event.key) - } else { - popularity[relayUrl] = mutableSetOf(event.key) + userList: Map>, + ignore: Set = setOf(), + ): Map> { + return mapOfSet { + userList.forEach { event -> + event.value.forEach { relay -> + if (relay !in ignore) { + add(relay, event.key) } } } } - - return popularity } /** @@ -53,30 +50,21 @@ class RelayListRecommendationProcessor { private fun filterValidRelays( userList: List, hasOnionConnection: Boolean = false, - ): MutableMap> { - val validWriteRelayUrls = mutableMapOf>() - - userList.forEach { event -> - event.writeRelays().forEach { relayUrl -> - if (!RelayUrlFormatter.isLocalHost(relayUrl) && (hasOnionConnection || !RelayUrlFormatter.isOnion(relayUrl))) { - RelayUrlFormatter.normalizeOrNull(relayUrl)?.let { normRelayUrl -> - val set = validWriteRelayUrls[event.pubKey] - if (set != null) { - set.add(normRelayUrl) - } else { - validWriteRelayUrls[event.pubKey] = mutableSetOf(normRelayUrl) - } + ): Map> { + return mapOfSet { + userList.forEach { event -> + event.writeRelaysNorm()?.forEach { relay -> + if (!relay.isLocalHost() && (hasOnionConnection || !relay.isOnion())) { + add(event.pubKey, relay) } } } } - - return validWriteRelayUrls } fun reliableRelaySetFor( - usersAndRelays: MutableMap>, - relayUrlsToIgnore: Set = emptySet(), + usersAndRelays: Map>, + relayUrlsToIgnore: Set = emptySet(), ): Set { // ignores users that are already being served by the list. val usersToServeInTheFirstRound = @@ -145,7 +133,7 @@ class RelayListRecommendationProcessor { fun reliableRelaySetFor( userList: List, - relayUrlsToIgnore: Set = emptySet(), + relayUrlsToIgnore: Set = emptySet(), hasOnionConnection: Boolean = false, ): Set = reliableRelaySetFor( @@ -155,7 +143,7 @@ class RelayListRecommendationProcessor { } class RelayRecommendation( - val url: String, + val relay: NormalizedRelayUrl, val requiredToNotMissEvents: Boolean, val users: Set, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayUrlFormatter.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayUrlFormatter.kt deleted file mode 100644 index fdfeb3bf6..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/RelayUrlFormatter.kt +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Copyright (c) 2025 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.quartz.nip65RelayList - -import org.czeal.rfc3986.URIReference - -class RelayUrlFormatter { - companion object { - fun displayUrl(url: String): String = - url - .trim() - .removePrefix("wss://") - .removePrefix("ws://") - .removeSuffix("/") - - fun isLocalHost(url: String) = url.contains("127.0.0.1") || url.contains("localhost") - - fun isOnion(url: String) = url.endsWith(".onion") || url.endsWith(".onion/") - - fun normalize(url: String): String { - val newUrl = - if (!url.startsWith("wss://") && !url.startsWith("ws://")) { - if (isOnion(url) || isLocalHost(url)) { - "ws://${url.trim()}" - } else { - "wss://${url.trim()}" - } - } else { - url.trim() - } - - return try { - URIReference.parse(newUrl).normalize().toString() - } catch (e: Exception) { - newUrl - } - } - - fun normalizeOrNull(url: String): String? { - val newUrl = - if (!url.startsWith("wss://") && !url.startsWith("ws://")) { - if (isOnion(url) || isLocalHost(url)) { - "ws://${url.trim()}" - } else { - "wss://${url.trim()}" - } - } else { - url.trim() - } - - return try { - URIReference.parse(newUrl).normalize().toString() - } catch (e: Exception) { - null - } - } - - fun getHttpsUrl(dirtyUrl: String): String = - if (dirtyUrl.contains("://")) { - dirtyUrl.replace("wss://", "https://").replace("ws://", "http://") - } else { - "https://$dirtyUrl" - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/tags/AdvertisedRelayInfoTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/tags/AdvertisedRelayInfoTag.kt new file mode 100644 index 000000000..07d6a1319 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip65RelayList/tags/AdvertisedRelayInfoTag.kt @@ -0,0 +1,125 @@ +/** + * Copyright (c) 2025 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.quartz.nip65RelayList.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure +import kotlin.text.isBlank + +class AdvertisedRelayInfo( + val relayUrl: NormalizedRelayUrl, + val type: AdvertisedRelayType, +) { + fun toTagArray() = assemble(relayUrl, type) + + companion object { + const val TAG_NAME = "r" + + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun notMatch(tag: Array) = !match(tag) + + @JvmStatic + fun parse(tag: Array): AdvertisedRelayInfo? { + ensure(match(tag)) { return null } + + val normalizedUrl = RelayUrlNormalizer.normalizeOrNull(tag[1]) + + ensure(normalizedUrl != null) { return null } + + val type = + when (tag.getOrNull(2)) { + AdvertisedRelayType.READ.code -> AdvertisedRelayType.READ + AdvertisedRelayType.WRITE.code -> AdvertisedRelayType.WRITE + else -> AdvertisedRelayType.BOTH + } + + return AdvertisedRelayInfo(normalizedUrl, type) + } + + @JvmStatic + fun parseRead(tag: Array): String? { + ensure(match(tag)) { return null } + ensure(AdvertisedRelayType.isRead(tag.getOrNull(2))) { return null } + + return tag[1] + } + + @JvmStatic + fun parseWrite(tag: Array): String? { + ensure(match(tag)) { return null } + ensure(AdvertisedRelayType.isWrite(tag.getOrNull(2))) { return null } + + return tag[1] + } + + @JvmStatic + fun parseReadNorm(tag: Array): NormalizedRelayUrl? { + ensure(match(tag)) { return null } + ensure(AdvertisedRelayType.isRead(tag.getOrNull(2))) { return null } + + return RelayUrlNormalizer.normalizeOrNull(tag[1]) + } + + @JvmStatic + fun parseWriteNorm(tag: Array): NormalizedRelayUrl? { + ensure(match(tag)) { return null } + ensure(AdvertisedRelayType.isWrite(tag.getOrNull(2))) { return null } + + return RelayUrlNormalizer.normalizeOrNull(tag[1]) + } + + @JvmStatic + fun assemble( + relay: NormalizedRelayUrl, + type: AdvertisedRelayType, + ): Array { + return if (type == AdvertisedRelayType.BOTH) { + arrayOf(TAG_NAME, relay.url) + } else { + arrayOf(TAG_NAME, relay.url, type.code) + } + } + } +} + +@Immutable +enum class AdvertisedRelayType( + val code: String, +) { + BOTH(""), + READ("read"), + WRITE("write"), + ; + + fun isRead() = this == READ || this == BOTH + + fun isWrite() = this == WRITE || this == BOTH + + companion object { + fun isRead(type: String?) = type == null || type.isBlank() || type == AdvertisedRelayType.READ.code + + fun isWrite(type: String?) = type == null || type.isBlank() || type == AdvertisedRelayType.WRITE.code + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityListEvent.kt deleted file mode 100644 index f304c1283..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/CommunityListEvent.kt +++ /dev/null @@ -1,279 +0,0 @@ -/** - * Copyright (c) 2025 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.quartz.nip72ModCommunities - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent.Companion.FIXED_D_TAG -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip19Bech32.parseAtag -import com.vitorpamplona.quartz.nip31Alts.AltTag -import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent -import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.pointerSizeInBytes -import kotlinx.collections.immutable.ImmutableSet -import kotlinx.collections.immutable.toImmutableSet - -@Immutable -class CommunityListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient var publicAndPrivateEventCache: ImmutableSet? = null - - override fun countMemory(): Long = - super.countMemory() + - 32 + (publicAndPrivateEventCache?.sumOf { pointerSizeInBytes + it.countMemory() } ?: 0L) // rough calculation - - override fun dTag() = FIXED_D_TAG - - fun publicAndPrivateEvents( - signer: NostrSigner, - onReady: (ImmutableSet) -> Unit, - ) { - publicAndPrivateEventCache?.let { eventList -> - onReady(eventList) - return - } - - privateTagsOrEmpty(signer) { - publicAndPrivateEventCache = - filterTagList("a", it) - .mapNotNull { ATag.parseAtag(it, null) } - .toImmutableSet() - - publicAndPrivateEventCache?.let { eventList -> - onReady(eventList) - } - } - } - - companion object { - const val KIND = 10004 - const val ALT = "Community List" - - fun blockListFor(pubKeyHex: HexKey): String = "$KIND:$pubKeyHex:" - - fun createListWithTag( - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) { - if (isPrivate) { - encryptTags(arrayOf(arrayOf(key, tag)), signer) { encryptedTags -> - create( - content = encryptedTags, - tags = emptyArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } else { - create( - content = "", - tags = arrayOf(arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun createListWithEvent( - address: ATag, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) = createListWithTag("a", address.toTag(), isPrivate, signer, createdAt, onReady) - - fun addEvents( - earlierVersion: CommunityListEvent, - listAddresses: List, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags.plus( - listAddresses.map { arrayOf("a", it.toTag()) }, - ), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags.plus( - listAddresses.map { arrayOf("a", it.toTag()) }, - ), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - - fun addEvent( - earlierVersion: CommunityListEvent, - address: ATag, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) = addTag(earlierVersion, "a", address.toTag(), isPrivate, signer, createdAt, onReady) - - fun addTag( - earlierVersion: CommunityListEvent, - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (!isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = privateTags.plus(element = arrayOf(key, tag)), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = earlierVersion.tags, - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf(key, tag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } - } - - fun removeEvent( - earlierVersion: CommunityListEvent, - address: ATag, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) = removeTag(earlierVersion, "a", address.toTag(), isPrivate, signer, createdAt, onReady) - - fun removeTag( - earlierVersion: CommunityListEvent, - key: String, - tag: String, - isPrivate: Boolean, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) { - earlierVersion.isTagged(key, tag, isPrivate, signer) { isTagged -> - if (isTagged) { - if (isPrivate) { - earlierVersion.privateTagsOrEmpty(signer) { privateTags -> - encryptTags( - privateTags = - privateTags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - ) { encryptedTags -> - create( - content = encryptedTags, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } else { - create( - content = earlierVersion.content, - tags = - earlierVersion.tags - .filter { it.size > 1 && !(it[0] == key && it[1] == tag) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - } - } - } - - fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (CommunityListEvent) -> Unit, - ) { - val newTags = - if (tags.any { it.size > 1 && it[0] == "alt" }) { - tags - } else { - tags + AltTag.assemble(ALT) - } - - signer.sign(createdAt, KIND, newTags, content, onReady) - } - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt index 5b404a33d..726a0b819 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt @@ -25,12 +25,24 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag.Companion.parseAsHint import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedATags import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parseAsHint import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag.Companion.parseAddressAsHint +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag.Companion.parseEventAsHint import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -43,7 +55,16 @@ class CommunityPostApprovalEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + fun containedPost(): Event? = try { content.ifBlank { null }?.let { fromJson(it) } @@ -68,6 +89,7 @@ class CommunityPostApprovalEvent( companion object { const val KIND = 4550 const val ALT_DESCRIPTION = "Community post approval" + val KIND_LIST = listOf(CommunityPostApprovalEvent.KIND) fun build( approvedPost: EventHintBundle, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt index 049831335..51feebe0d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/CommunityDefinitionEvent.kt @@ -24,8 +24,20 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag.Companion.parseAsHint import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag.Companion.parseAddressAsHint +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag.Companion.parseEventAsHint import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.DescriptionTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ImageTag @@ -44,7 +56,19 @@ class CommunityDefinitionEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + + override fun addressHints() = + tags.mapNotNull(ATag::parseAsHint) + + tags.mapNotNull(QTag::parseAddressAsHint) + + tags.mapNotNull(RelayTag::parse).mapNotNull { AddressHint(addressTag(), it.url) } + + override fun pubKeyHints() = tags.mapNotNull(ModeratorTag::parseAsHint) + fun name() = tags.firstNotNullOfOrNull(NameTag::parse) fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) @@ -59,6 +83,8 @@ class CommunityDefinitionEvent( fun relays() = tags.mapNotNull(RelayTag::parse) + fun relayUrls() = tags.mapNotNull(RelayTag::parseUrls) + companion object { const val KIND = 34550 const val ALT_DESCRIPTION = "Community definition" diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ModeratorTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ModeratorTag.kt index 199446bfa..ba33b8abb 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ModeratorTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/ModeratorTag.kt @@ -24,14 +24,18 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @Immutable data class ModeratorTag( override val pubKey: String, - override val relayHint: String?, + override val relayHint: NormalizedRelayUrl?, val role: String?, ) : PubKeyReferenceTag { fun toTagArray() = assemble(pubKey, relayHint, role) @@ -44,7 +48,10 @@ data class ModeratorTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - return ModeratorTag(tag[1], tag.getOrNull(2), tag.getOrNull(3)) + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ModeratorTag(tag[1], hint, tag.getOrNull(3)) } @JvmStatic @@ -55,11 +62,24 @@ data class ModeratorTag( return tag[1] } + @JvmStatic + fun parseAsHint(tag: Array): PubKeyHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == ParticipantTag.Companion.TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(hint != null) { return null } + + return PubKeyHint(tag[1], hint) + } + @JvmStatic fun assemble( pubkey: HexKey, - relayHint: String?, + relayHint: NormalizedRelayUrl?, role: String?, - ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint, role) + ) = arrayOfNotNull(TAG_NAME, pubkey, relayHint?.url, role) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt index 948f4f35b..09e62790d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt @@ -21,11 +21,13 @@ package com.vitorpamplona.quartz.nip72ModCommunities.definition.tags import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure class RelayTag( - val url: String, + val url: NormalizedRelayUrl, val marker: String? = null, ) { fun toTagArray() = assemble(url, marker) @@ -38,13 +40,27 @@ class RelayTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } - return RelayTag(tag[1], tag.getOrNull(2)) + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) ?: return null + + return RelayTag(relay, tag.getOrNull(2)) + } + + @JvmStatic + fun parseUrls(tag: Array): NormalizedRelayUrl? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val relay = RelayUrlNormalizer.normalizeOrNull(tag[1]) ?: return null + + return relay } @JvmStatic fun assemble( - url: String, + relay: NormalizedRelayUrl, marker: String? = null, - ) = arrayOfNotNull(TAG_NAME, url, marker) + ) = arrayOfNotNull(TAG_NAME, relay.url, marker) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt new file mode 100644 index 000000000..2d5acf30d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt @@ -0,0 +1,272 @@ +/** + * Copyright (c) 2025 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.quartz.nip72ModCommunities.follow + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayBuilder +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.tryAndWait +import kotlin.collections.map +import kotlin.collections.plus +import kotlin.coroutines.resume + +@Immutable +class CommunityListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient var publicAndPrivateAddressCache: Set? = null + + fun publicCommunities() = tags.mapNotNull(ATag::parseAsHint) + + fun publicCommunityIds() = tags.mapNotNull(ATag::parseAddressId) + + fun publicAndPrivateCommunities( + signer: NostrSigner, + onReady: (Set) -> Unit, + ) { + publicAndPrivateAddressCache?.let { eventList -> + onReady(eventList) + return + } + + mergeTagList(signer) { + val set = it.mapNotNull(ATag::parseAsHint).toSet() + publicAndPrivateAddressCache = set + onReady(set) + } + } + + suspend fun publicAndPrivateCommunities(signer: NostrSigner): Set? { + publicAndPrivateAddressCache?.let { return it } + + return tryAndWait { continuation -> + publicAndPrivateCommunities(signer) { privateTagList -> + continuation.resume(privateTagList) + } + } + } + + companion object { + const val KIND = 10004 + const val ALT = "Community List" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + private fun createCommunityBase( + tags: Array>, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + PrivateTagArrayBuilder.create( + tags, + isPrivate, + signer, + ) { encryptedContent, newTags -> + create(encryptedContent, newTags, signer, createdAt, onReady) + } + } + + fun createCommunity( + community: EventHintBundle, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) = createCommunityBase( + tags = arrayOf(ATag.assemble(community.event.address(), community.relay)), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + + fun createCommunity( + community: ATag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) = createCommunityBase( + tags = arrayOf(community.toATagArray()), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + + fun createCommunities( + communities: List>, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) = createCommunityBase( + tags = communities.map { ATag.assemble(it.event.address().toValue(), it.relay) }.toTypedArray(), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + onReady = onReady, + ) + + fun removeCommunity( + earlierVersion: CommunityListEvent, + community: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + PrivateTagArrayBuilder.removeAll( + earlierVersion, + ATag.assemble(community, null), + signer, + ) { encryptedContent, newTags -> + create(encryptedContent, newTags, signer, createdAt, onReady) + } + } + + private fun addCommunityBase( + earlierVersion: CommunityListEvent, + newTags: Array>, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + PrivateTagArrayBuilder.addAll( + earlierVersion, + newTags, + isPrivate, + signer, + ) { encryptedContent, newTags -> + create(encryptedContent, newTags, signer, createdAt, onReady) + } + } + + fun addCommunity( + earlierVersion: CommunityListEvent, + community: ATag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) = addCommunityBase( + earlierVersion, + arrayOf(community.toATagArray()), + isPrivate, + signer, + createdAt, + onReady, + ) + + fun addCommunity( + earlierVersion: CommunityListEvent, + community: EventHintBundle, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) = addCommunityBase( + earlierVersion, + arrayOf(community.toATag().toATagArray()), + isPrivate, + signer, + createdAt, + onReady, + ) + + fun addCommunity( + earlierVersion: CommunityListEvent, + community: AddressHint, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) = addCommunityBase( + earlierVersion, + arrayOf(ATag.assemble(community.addressId, community.relay)), + isPrivate, + signer, + createdAt, + onReady, + ) + + fun addCommunities( + earlierVersion: CommunityListEvent, + communities: List>, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) = addCommunityBase( + earlierVersion, + communities.map { it.toATag().toATagArray() }.toTypedArray(), + isPrivate, + signer, + createdAt, + onReady, + ) + + private fun create( + content: String, + tags: Array>, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (CommunityListEvent) -> Unit, + ) { + val newTags = + if (tags.any { it.size > 1 && it[0] == "alt" }) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + signer.sign(createdAt, KIND, newTags, content, onReady) + } + + fun create( + list: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): CommunityListEvent? { + val tags = list.map { ATag.assemble(it.addressId, it.relay) }.toTypedArray() + return signer.sign(createdAt, KIND, tags, "") + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/TagArrayBuilderExt.kt new file mode 100644 index 000000000..098657d26 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip72ModCommunities/follow/TagArrayBuilderExt.kt @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2025 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.quartz.nip72ModCommunities.follow + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address + +fun TagArrayBuilder.followCommunity( + address: Address, + relayUrl: NormalizedRelayUrl, +) = addUnique(ATag.assemble(address, relayUrl)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt index 9cc32f363..f55600e48 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt @@ -26,9 +26,18 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parseAsHint import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseAsHint import com.vitorpamplona.quartz.nip01Core.tags.references.reference import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag @@ -46,7 +55,16 @@ class GoalEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + fun topics() = hashtags() fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt index e9eac01f6..7fbad7d95 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip84Highlights/HighlightEvent.kt @@ -22,13 +22,25 @@ package com.vitorpamplona.quartz.nip84Highlights import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag.Companion.parseAsHint import com.vitorpamplona.quartz.nip01Core.tags.addressables.firstTaggedATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.firstTaggedAddress +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseAsHint import com.vitorpamplona.quartz.nip01Core.tags.people.firstTaggedUser import com.vitorpamplona.quartz.nip01Core.tags.references.ReferenceTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag.Companion.parseAddressAsHint +import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag.Companion.parseEventAsHint import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip84Highlights.tags.CommentTag @@ -44,7 +56,16 @@ class HighlightEvent( content: String, sig: HexKey, ) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), - RootScope { + RootScope, + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) + + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(QTag::parseAddressAsHint) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + fun inUrl() = tags.firstNotNullOfOrNull(ReferenceTag::parse) fun author() = firstTaggedUser() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/ClientTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/ClientTag.kt new file mode 100644 index 000000000..2810fe19e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/ClientTag.kt @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2025 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.quartz.nip89AppHandlers.clientTag + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip46RemoteSigner.getOrNull +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +class ClientTag( + val name: String, + val address: Address?, + val relayHint: NormalizedRelayUrl?, +) { + fun toTagArray() = assemble(name, address, relayHint) + + companion object { + const val TAG_NAME = "client" + + @JvmStatic + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + @JvmStatic + fun parse(tag: Array): ClientTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + val address = tag.getOrNull(2)?.let { Address.parse(it) } + val relayHint = tag.getOrNull(3)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ClientTag( + tag[1], + address, + relayHint, + ) + } + + @JvmStatic + fun assemble(name: String) = arrayOfNotNull(TAG_NAME, name) + + @JvmStatic + fun assemble( + name: String, + address: String? = null, + relayHint: NormalizedRelayUrl? = null, + ) = arrayOfNotNull(TAG_NAME, name, address, relayHint?.url) + + @JvmStatic + fun assemble( + name: String, + address: Address? = null, + relayHint: NormalizedRelayUrl? = null, + ) = assemble(name, address?.toValue(), relayHint) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt new file mode 100644 index 000000000..81b92c039 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2025 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.quartz.nip89AppHandlers.clientTag + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount + +fun Event.client() = tags.zapraiserAmount() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SubscriptionCollection.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt similarity index 73% rename from quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SubscriptionCollection.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt index a3d70ff3e..566492c98 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/SubscriptionCollection.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt @@ -18,20 +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.quartz.nip01Core.relay +package com.vitorpamplona.quartz.nip89AppHandlers.clientTag import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint -interface SubscriptionCollection { - fun isActive(subscriptionId: String): Boolean +fun TagArrayBuilder.client(name: String) = addUnique(ClientTag.assemble(name)) - fun getFilters(subscriptionId: String): List - - fun allSubscriptions(): List - - fun match( - subscriptionId: String, - event: Event, - ): Boolean -} +fun TagArrayBuilder.client( + name: String, + address: AddressHint, +) = addUnique(ClientTag.assemble(name, address.addressId, address.relay)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayExt.kt new file mode 100644 index 000000000..3516fad88 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayExt.kt @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2025 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.quartz.nip89AppHandlers.clientTag + +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.client() = this.mapNotNull(ClientTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEvent.kt index d82fe0522..c8266e82a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/AppRecommendationEvent.kt @@ -24,6 +24,8 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip31Alts.alt @@ -40,7 +42,10 @@ class AppRecommendationEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider { + override fun addressHints() = tags.mapNotNull(RecommendationTag::parseAsHint) + fun recommendations() = tags.mapNotNull(RecommendationTag::parse) fun recommendationAddresses() = tags.mapNotNull(RecommendationTag::parseAddressId) @@ -51,7 +56,7 @@ class AppRecommendationEvent( class AppRecommendationItem( val appDefinitionEvent: AppDefinitionEvent, - val relayHint: String?, + val relayHint: NormalizedRelayUrl?, val platform: PlatformType, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayBuilderExt.kt index 68f7318f5..96122fd36 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayBuilderExt.kt @@ -21,18 +21,19 @@ package com.vitorpamplona.quartz.nip89AppHandlers.recommendation import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags.RecommendationTag fun TagArrayBuilder.recommend( addressId: String, - relay: String?, + relay: NormalizedRelayUrl?, platform: String?, ) = add(RecommendationTag.assemble(addressId, relay, platform)) fun TagArrayBuilder.recommend( address: Address, - relay: String?, + relay: NormalizedRelayUrl?, platform: String?, ) = add(RecommendationTag.assemble(address, relay, platform)) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/tags/RecommendationTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/tags/RecommendationTag.kt index 1a48b20fa..4056e736e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/tags/RecommendationTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/tags/RecommendationTag.kt @@ -23,6 +23,10 @@ package com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -30,7 +34,7 @@ import com.vitorpamplona.quartz.utils.ensure @Immutable class RecommendationTag( val address: Address, - val relay: String? = null, + val relay: NormalizedRelayUrl? = null, val platform: String? = null, ) { fun toTagArray() = assemble(address, relay, platform) @@ -47,7 +51,8 @@ class RecommendationTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } val address = Address.parse(tag[1]) ?: return null - return RecommendationTag(address, tag.getOrNull(2), tag.getOrNull(3)) + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + return RecommendationTag(address, relayHint, tag.getOrNull(3)) } @JvmStatic @@ -58,18 +63,31 @@ class RecommendationTag( return tag[1] } + @JvmStatic + fun parseAsHint(tag: Array): AddressHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == ATag.Companion.TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[1].contains(':')) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) ?: return null + + return AddressHint(tag[1], relayHint) + } + @JvmStatic fun assemble( addressId: String, - relay: String?, + relay: NormalizedRelayUrl?, platform: String?, - ) = arrayOfNotNull(TAG_NAME, addressId, relay, platform) + ) = arrayOfNotNull(TAG_NAME, addressId, relay?.url, platform) @JvmStatic fun assemble( address: Address, - relay: String?, + relay: NormalizedRelayUrl?, platform: String?, - ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay, platform) + ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url, platform) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt index c2c44c5b8..c31dd4a47 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryRequestEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -45,7 +46,7 @@ class NIP90ContentDiscoveryRequestEvent( fun create( dvmPublicKey: HexKey, forUser: HexKey, - relays: Set, + relays: Set, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (NIP90ContentDiscoveryRequestEvent) -> Unit, @@ -54,7 +55,7 @@ class NIP90ContentDiscoveryRequestEvent( val tags = mutableListOf>() tags.add(arrayOf("p", dvmPublicKey)) tags.add(AltTag.assemble(ALT)) - tags.add(arrayOf("relays") + relays.toTypedArray()) + tags.add(arrayOf("relays") + relays.map { it.url }.toTypedArray()) tags.add(arrayOf("param", "max_results", "200")) tags.add(arrayOf("param", "user", forUser)) signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomAuthorizationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt similarity index 98% rename from quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomAuthorizationEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt index 4fa1ad9ba..d2db68483 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomAuthorizationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.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.quartz.blossom +package com.vitorpamplona.quartz.nipB7Blossom import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomServersEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.kt similarity index 98% rename from quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomServersEvent.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.kt index 71df0ede5..817ed20d2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/blossom/BlossomServersEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.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.quartz.blossom +package com.vitorpamplona.quartz.nipB7Blossom import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeCache.kt index fd0a4bff4..65d5cff60 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeCache.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/LargeCache.kt @@ -26,6 +26,8 @@ import java.util.function.BiConsumer class LargeCache { private val cache = ConcurrentSkipListMap() + fun keys() = cache.keys + fun values() = cache.values fun get(key: K) = cache.get(key) @@ -36,6 +38,8 @@ class LargeCache { fun isEmpty() = cache.isEmpty() + fun clear() = cache.clear() + fun containsKey(key: K) = cache.containsKey(key) fun put( @@ -59,6 +63,19 @@ class LargeCache { } } + fun createIfAbsent( + key: K, + builder: (key: K) -> V, + ): Boolean { + val value = cache.get(key) + return if (value != null) { + false + } else { + val newObject = builder(key) + cache.putIfAbsent(key, newObject) == null + } + } + fun forEach(consumer: BiConsumer) { innerForEach(consumer) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/MapOfSetBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/MapOfSetBuilder.kt new file mode 100644 index 000000000..698daf72b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/MapOfSetBuilder.kt @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2025 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.quartz.utils + +import android.R.attr.value + +class MapOfSetBuilder { + val data = mutableMapOf>() + + fun add( + key: K, + value: V, + ) { + val set = data[key] + if (set == null) { + data.put(key, mutableSetOf(value)) + } else { + set.add(value) + } + } + + fun add( + key: K, + value: Set, + ) { + val set = data[key] + if (set == null) { + data.put(key, value.toMutableSet()) + } else { + set.addAll(value) + } + } + + fun add(map: Map>) { + map.forEach { + add(it.key, it.value) + } + } + + fun build(): Map> = data +} + +fun mapOfSet(init: MapOfSetBuilder.() -> Unit): Map> { + val data = MapOfSetBuilder() + data.init() + return data.build() +} + +fun merge(maps: List>>): Map> { + return mapOfSet { + maps.forEach { map -> + add(map) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ParallelUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ParallelUtils.kt rename to quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.kt index 7b48e4c07..bf46ca8af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ParallelUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.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 +package com.vitorpamplona.quartz.utils import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/RandomInstance.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/RandomInstance.kt index 84b90b82c..29458b572 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/RandomInstance.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/RandomInstance.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.utils +import com.vitorpamplona.quartz.utils.RandomInstance.charPool import java.security.SecureRandom object RandomInstance { @@ -28,4 +29,10 @@ object RandomInstance { fun int(bound: Int = Int.MAX_VALUE) = randomizer.nextInt(bound) fun bytes(size: Int) = ByteArray(size).also { randomizer.nextBytes(it) } + + val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') + + fun randomChar() = charPool[randomizer.nextInt(charPool.size)] + + fun randomChars(size: Int = 16) = String(CharArray(size) { randomChar() }) } diff --git a/quartz/src/test/java/android/util/Log.java b/quartz/src/test/java/android/util/Log.java new file mode 100644 index 000000000..af85d0b47 --- /dev/null +++ b/quartz/src/test/java/android/util/Log.java @@ -0,0 +1,35 @@ +package android.util; + +public class Log { + public static int d(String tag, String msg) { + System.out.println("DEBUG: " + tag + ": " + msg); + return 0; + } + + public static int i(String tag, String msg) { + System.out.println("INFO: " + tag + ": " + msg); + return 0; + } + + public static int w(String tag, String msg) { + System.out.println("WARN: " + tag + ": " + msg); + return 0; + } + + public static int w(String tag, String msg, Throwable e) { + System.out.println("WARN: " + tag + ": " + msg); + e.printStackTrace(); + return 0; + } + + public static int e(String tag, String msg) { + System.out.println("ERROR: " + tag + ": " + msg); + return 0; + } + + public static int e(String tag, String msg, Throwable e) { + System.out.println("ERROR: " + tag + ": " + msg); + e.printStackTrace(); + return 0; + } +} \ No newline at end of file diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt index 341e4074c..4a265ad02 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.hints import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.usedMemoryMb @@ -62,7 +63,7 @@ class HintIndexerTest { .getResourceAsStream("relayDB.txt") ?.readAllBytes() .toString() - .split("\n") + .split("\n").mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } val indexer by lazy { System.gc() @@ -119,31 +120,31 @@ class HintIndexerTest { @Test fun runProbExistingKeys() = assert99PercentSucess { - indexer.getKey(keys.random()).isNotEmpty() + indexer.hintsForKey(keys.random()).isNotEmpty() } @Test fun runProbNewKeys() = assert99PercentSucess { - indexer.getKey(RandomInstance.bytes(32)).isEmpty() + indexer.hintsForKey(RandomInstance.bytes(32)).isEmpty() } @Test fun runProbExistingEventIds() = assert99PercentSucess { - indexer.getEvent(eventIds.random()).isNotEmpty() + indexer.hintsForEvent(eventIds.random()).isNotEmpty() } @Test fun runProbNewEventIds() = assert99PercentSucess { - indexer.getEvent(RandomInstance.bytes(32)).isEmpty() + indexer.hintsForEvent(RandomInstance.bytes(32)).isEmpty() } @Test fun runProbExistingAddresses() = assert99PercentSucess { - indexer.getAddress(addresses.random()).isNotEmpty() + indexer.hintsForAddress(addresses.random()).isNotEmpty() } @Test @@ -156,6 +157,6 @@ class HintIndexerTest { randomChars(10), ) - indexer.getAddress(newAddress).isEmpty() + indexer.hintsForAddress(newAddress).isEmpty() } } diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlFormatterTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlFormatterTest.kt new file mode 100644 index 000000000..fd936867a --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlFormatterTest.kt @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2025 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.quartz.nip01Core.relay + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import junit.framework.TestCase.assertEquals +import org.junit.Test + +class RelayUrlFormatterTest { + @Test + fun format() { + assertEquals("wss://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("wss://nostr.mom")?.url) + assertEquals("wss://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("nostr.mom")?.url) + assertEquals("ws://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("ws://nostr.mom")?.url) + assertEquals("wss://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("wss://nostr.mom/")?.url) + assertEquals("wss://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("https://nostr.mom/")?.url) + assertEquals("ws://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("http://nostr.mom/")?.url) + + assertEquals("wss://localhost:3030/", RelayUrlNormalizer.normalizeOrNull("wss://localhost:3030")?.url) + assertEquals("ws://localhost:3030/", RelayUrlNormalizer.normalizeOrNull("localhost:3030")?.url) + + assertEquals("wss://a.onion/", RelayUrlNormalizer.normalizeOrNull("wss://a.onion")?.url) + assertEquals("ws://a.onion/", RelayUrlNormalizer.normalizeOrNull("a.onion")?.url) + assertEquals("wss://a.onion/", RelayUrlNormalizer.normalizeOrNull("wss://a.onion/")?.url) + assertEquals("ws://a.onion/", RelayUrlNormalizer.normalizeOrNull("a.onion/")?.url) + + assertEquals("wss://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("wss://nostr.mom")?.url) + } +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt index 12b91c636..f1143e1ce 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.quartz.nip19Bech32 +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress @@ -107,7 +109,7 @@ class NIP19ParserTest { Assert.assertNotNull(actual) Assert.assertTrue(actual?.entity is NProfile) Assert.assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", (actual?.entity as? NProfile)?.hex) - Assert.assertEquals("wss://vitor.nostr1.com/", (actual?.entity as? NProfile)?.relay?.first()) + Assert.assertEquals(NormalizedRelayUrl("wss://vitor.nostr1.com/"), (actual?.entity as? NProfile)?.relay?.first()) } @Test() @@ -169,7 +171,7 @@ class NIP19ParserTest { "30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920", (result?.entity as? NAddress)?.aTag(), ) - assertEquals("wss://relay.damus.io", (result?.entity as? NAddress)?.relay?.get(0)) + assertEquals(NormalizedRelayUrl("wss://relay.damus.io/"), (result?.entity as? NAddress)?.relay?.get(0)) } @Test @@ -177,7 +179,7 @@ class NIP19ParserTest { val address = ATag.parse( "30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920", - "wss://relay.damus.io", + "relay.damus.io", ) assertEquals(30023, address?.kind) assertEquals( @@ -185,9 +187,9 @@ class NIP19ParserTest { address?.pubKeyHex, ) assertEquals("89de7920", address?.dTag) - assertEquals("wss://relay.damus.io", address?.relay) + assertEquals("wss://relay.damus.io/", address?.relay?.url) assertEquals( - "naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", + "naddr1qqyrswtyv5mnjv3sqy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7q3q68nqgewzkamnyh53x0epqrftkv2pdh9gzr6558v4vetzr3w7uxfsxpqqqp65wmpxfdu", address?.toNAddr(), ) } @@ -229,10 +231,10 @@ class NIP19ParserTest { 30023, "d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193", "89de7920", - "wss://relay.damus.io", + RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io")!!, ) assertEquals( - "naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", + "naddr1qqyrswtyv5mnjv3sqy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7q3q68nqgewzkamnyh53x0epqrftkv2pdh9gzr6558v4vetzr3w7uxfsxpqqqp65wmpxfdu", address.toNAddr(), ) } @@ -298,7 +300,7 @@ class NIP19ParserTest { assertNotNull(result) assertEquals("d768c1b28eb94c7d90aa6b9152021fb69c8e34b452f870e2e42341ea7f9796ca", result?.hex) - assertEquals("wss://relay.nostr.bg/", result?.relay?.firstOrNull()) + assertEquals("wss://relay.nostr.bg/", result?.relay?.firstOrNull()?.url) assertEquals("cac0e43235806da094f0787a5b04e29ad04cb1a3c7ea5cf61edc1c338734082b", result?.author) assertEquals(1, result?.kind) } @@ -322,7 +324,7 @@ class NIP19ParserTest { assertNotNull(result) assertEquals("9677aa74676757cafad5910f46a1a35f58f7bae253b03eb8ffa70db2fb4643ea", result?.hex) - assertEquals("wss://relay.westernbtc.com", result?.relay?.firstOrNull()) + assertEquals("wss://relay.westernbtc.com/", result?.relay?.firstOrNull()?.url) assertEquals(null, result?.author) assertEquals(null, result?.kind) } @@ -337,7 +339,10 @@ class NIP19ParserTest { assertNotNull(result) assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result?.hex) - assertEquals("wss://nostr.mom", result?.relay?.get(0)) + assertEquals( + NormalizedRelayUrl("wss://nostr.mom/"), + result?.relay?.get(0), + ) assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result?.author) assertEquals(1, result?.kind) } @@ -352,7 +357,7 @@ class NIP19ParserTest { assertNotNull(result) assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result?.hex) - assertEquals("wss://relay.damus.io", result?.relay?.get(0)) + assertEquals(NormalizedRelayUrl("wss://relay.damus.io/"), result?.relay?.get(0)) assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) assertEquals(1, result?.kind) } @@ -362,14 +367,14 @@ class NIP19ParserTest { val result = Nip19Parser .uriToRoute( - "nostr:nevent1qqsg6gechd3dhzx38n4z8a2lylzgsmmgeamhmtzz72m9ummsnf0xjfspsdmhxue69uhkummn9ekx7mpvwaehxw309ahx7um5wghx77r5wghxgetk93mhxue69uhhyetvv9ujumn0wd68ytnzvuk8wumn8ghj7mn0wd68ytn9d9h82mny0fmkzmn6d9njuumsv93k2trhwden5te0wfjkccte9ehx7um5wghxyctwvsk8wumn8ghj7un9d3shjtnyv9kh2uewd9hs3kqsdn", + "nostr:nevent1qqsg6gechd3dhzx38n4z8a2lylzgsmmgeamhmtzz72m9ummsnf0xjfsppemhxue69uhkummn9ekx7mp0qy2hwumn8ghj7mn0wd68ytn00p68ytnyv4mz7qg4waehxw309aex2mrp0yhxummnw3ezucn89uqjqamnwvaz7tmwdaehgu3wv45kuatwv3a8wctw0f5kwtnnwpskxef0qythwumn8ghj7un9d3shjtnwdaehgu3wvfskuep0qy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7dyp4wy", )?.entity as? NEvent assertNotNull(result) assertEquals("8d2338bb62db88d13cea23f55f27c4886f68cf777dac42f2b65e6f709a5e6926", result?.hex) assertEquals( - "wss://nos.lol,wss://nostr.oxtr.dev,wss://relay.nostr.bg,wss://nostr.einundzwanzig.space,wss://relay.nostr.band,wss://relay.damus.io", - result?.relay?.joinToString(","), + "wss://nos.lol/,wss://nostr.oxtr.dev/,wss://relay.nostr.bg/,wss://nostr.einundzwanzig.space/,wss://relay.nostr.band/,wss://relay.damus.io/", + result?.relay?.joinToString(",") { it.url }, ) } @@ -383,7 +388,7 @@ class NIP19ParserTest { assertNotNull(result) assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result?.hex) - assertEquals("wss://relay.damus.io", result?.relay?.get(0)) + assertEquals("wss://relay.damus.io/", result?.relay?.get(0)?.url) assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) assertEquals(1, result?.kind) } @@ -422,10 +427,10 @@ class NIP19ParserTest { "1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", 1, - "wss://relay.damus.io", + RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io"), ) assertEquals( - "nevent1qqsplpuwsgrrmq85rfup6w3w777rxmcmadu590emfx6z4msj2844euqpz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqye3a70w", + "nevent1qqsplpuwsgrrmq85rfup6w3w777rxmcmadu590emfx6z4msj2844euqpz4mhxue69uhhyetvv9ujuerpd46hxtnfduhsygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqqqqsqvc0ku", nevent, ) } diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessorTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessorTest.kt index 1b40d70fa..b90ade333 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessorTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessorTest.kt @@ -20,29 +20,32 @@ */ package com.vitorpamplona.quartz.nip65RelayList +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue import org.junit.Test class RelayListRecommendationProcessorTest { + fun norm(str: String) = RelayUrlNormalizer.normalizeOrNull(str)!! + val userList = mutableMapOf( - "User1" to mutableSetOf("wss://relay1.com", "wss://relay2.com", "wss://relay3.com"), - "User2" to mutableSetOf("wss://relay4.com", "wss://relay5.com", "wss://relay6.com"), - "User3" to mutableSetOf("wss://relay1.com", "wss://relay4.com", "wss://relay6.com"), - "User4" to mutableSetOf("wss://relay2.com", "wss://relay1.com", "wss://relay4.com"), + "User1" to mutableSetOf(norm("wss://relay1.com"), norm("wss://relay2.com"), norm("wss://relay3.com")), + "User2" to mutableSetOf(norm("wss://relay4.com"), norm("wss://relay5.com"), norm("wss://relay6.com")), + "User3" to mutableSetOf(norm("wss://relay1.com"), norm("wss://relay4.com"), norm("wss://relay6.com")), + "User4" to mutableSetOf(norm("wss://relay2.com"), norm("wss://relay1.com"), norm("wss://relay4.com")), ) @Test fun testTranspose() { assertEquals( mapOf( - "wss://relay1.com" to listOf("User1", "User3", "User4"), - "wss://relay2.com" to listOf("User1", "User4"), - "wss://relay3.com" to listOf("User1"), - "wss://relay4.com" to listOf("User2", "User3", "User4"), - "wss://relay5.com" to listOf("User2"), - "wss://relay6.com" to listOf("User2", "User3"), + norm("wss://relay1.com") to listOf("User1", "User3", "User4"), + norm("wss://relay2.com") to listOf("User1", "User4"), + norm("wss://relay3.com") to listOf("User1"), + norm("wss://relay4.com") to listOf("User2", "User3", "User4"), + norm("wss://relay5.com") to listOf("User2"), + norm("wss://relay6.com") to listOf("User2", "User3"), ).toString(), RelayListRecommendationProcessor.transpose(userList).toString(), ) @@ -54,7 +57,7 @@ class RelayListRecommendationProcessorTest { val rec1 = recommendations[0] - assertEquals("wss://relay1.com", rec1.url) + assertEquals("wss://relay1.com/", rec1.relay.url) assertEquals(true, rec1.requiredToNotMissEvents) assertTrue("User1" in rec1.users) assertTrue("User2" !in rec1.users) @@ -63,7 +66,7 @@ class RelayListRecommendationProcessorTest { val rec2 = recommendations[1] - assertEquals("wss://relay4.com", rec2.url) + assertEquals("wss://relay4.com/", rec2.relay.url) assertEquals(true, rec2.requiredToNotMissEvents) assertTrue("User1" !in rec2.users) assertTrue("User2" in rec2.users) @@ -72,7 +75,7 @@ class RelayListRecommendationProcessorTest { val rec3 = recommendations[2] - assertEquals("wss://relay2.com", rec3.url) + assertEquals("wss://relay2.com/", rec3.relay.url) assertEquals(false, rec3.requiredToNotMissEvents) assertTrue("User1" in rec3.users) assertTrue("User2" !in rec3.users) @@ -81,7 +84,7 @@ class RelayListRecommendationProcessorTest { val rec4 = recommendations[3] - assertEquals("wss://relay5.com", rec4.url) + assertEquals("wss://relay5.com/", rec4.relay.url) assertEquals(false, rec4.requiredToNotMissEvents) assertTrue("User1" !in rec4.users) assertTrue("User2" in rec4.users)