From 3a9622fb1eecc290915c844d0f647bec33167f23 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 01:09:52 +0000 Subject: [PATCH] feat: add RelayFeedScreen for browsing and following relay-specific feeds - Add RelayFeedScreen similar to HashtagScreen but filtered by relay URL - Follow/Unfollow buttons add/remove the relay from Favorite Relay list - New post FAB opens the standard note composer (Route.NewShortNote) - Add RelayFeedFilter (DAL) to show notes seen from a specific relay - Add SingleRelayFeedViewModel backed by RelayFeedFilter - Add datasource layer: RelayFeedFilterAssembler, SubAssembler, and FilterPostsByRelay for subscribing to relay-specific note streams - Add Route.RelayFeed(url) and register in AppNavigation - Add relayFeed to RelaySubscriptionsCoordinator - Add FavoriteRelayListState.addRelay/removeRelay for single-relay ops - Add Account.followFavoriteRelay/unfollowFavoriteRelay - Add AccountViewModel.followFavoriteRelay/unfollowFavoriteRelay - Add observeUserIsFollowingRelay composable observer https://claude.ai/code/session_013gNQjBzeSSmesYow7HfAjW --- .../vitorpamplona/amethyst/model/Account.kt | 4 + .../favoriteRelays/FavoriteRelayListState.kt | 13 ++ .../RelaySubscriptionsCoordinator.kt | 3 + .../reqCommand/user/UserObservers.kt | 21 +++ .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 5 + .../ui/screen/loggedIn/AccountViewModel.kt | 4 + .../loggedIn/relay/NewRelayNoteButton.kt | 58 +++++++ .../screen/loggedIn/relay/RelayFeedScreen.kt | 149 ++++++++++++++++++ .../loggedIn/relay/dal/RelayFeedFilter.kt | 56 +++++++ .../relay/dal/SingleRelayFeedViewModel.kt | 45 ++++++ .../relay/datasource/FilterPostsByRelay.kt | 89 +++++++++++ .../datasource/RelayFeedFilterAssembler.kt | 45 ++++++ .../RelayFeedFilterAssemblerSubscription.kt | 42 +++++ .../datasource/RelayFeedFilterSubAssembler.kt | 41 +++++ 15 files changed, 577 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/NewRelayNoteButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/RelayFeedScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/dal/RelayFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/dal/SingleRelayFeedViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterSubAssembler.kt 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 4b7824e9f..bb567ac1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1922,6 +1922,10 @@ class Account( suspend fun saveFavoriteRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(favoriteRelayList.saveRelayList(trustedRelays)) + suspend fun followFavoriteRelay(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(favoriteRelayList.addRelay(url)) + + suspend fun unfollowFavoriteRelay(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(favoriteRelayList.removeRelay(url)) + suspend fun saveBlockedRelayList(blockedRelays: List) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays)) suspend fun sendNip65RelayList(relays: List) = sendLiterallyEverywhere(nip65RelayList.saveRelayList(relays)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListState.kt index e77ef1079..d3f78e344 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListState.kt @@ -84,6 +84,19 @@ class FavoriteRelayListState( emptySet(), ) + suspend fun addRelay(relay: NormalizedRelayUrl): FavoriteRelayListEvent { + val current = normalizeFavoriteRelayListWithBackupNoDefaults(favoriteListNote).toMutableList() + if (relay !in current) current.add(relay) + return saveRelayList(current) + } + + suspend fun removeRelay(relay: NormalizedRelayUrl): FavoriteRelayListEvent? { + val current = normalizeFavoriteRelayListWithBackupNoDefaults(favoriteListNote).toMutableList() + if (relay !in current) return null + current.remove(relay) + return saveRelayList(current) + } + suspend fun saveRelayList(FavoriteRelays: List): FavoriteRelayListEvent { val relayListForFavorite = getFavoriteRelayList() 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 8a0335bf0..00ec95c79 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 @@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.Discove import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource.RelayFeedFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler @@ -79,6 +80,7 @@ class RelaySubscriptionsCoordinator( val profile = UserProfileFilterAssembler(client) val hashtags = HashtagFilterAssembler(client) val geohashes = GeoHashFilterAssembler(client) + val relayFeed = RelayFeedFilterAssembler(client) val followPacks = FollowPackFeedFilterAssembler(client) val chess = ChessFilterAssembler(client) @@ -103,6 +105,7 @@ class RelaySubscriptionsCoordinator( profile, hashtags, geohashes, + relayFeed, chess, nwc, ) 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 44025e5d3..a4739fecb 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 @@ -355,6 +355,27 @@ fun observeUserIsFollowingGeohash( return flow.collectAsStateWithLifecycle(geohash in accountViewModel.account.geohashList.flow.value) } +@SuppressLint("StateFlowValueCalledInComposition") +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserIsFollowingRelay( + relayUrl: NormalizedRelayUrl, + accountViewModel: AccountViewModel, +): State { + val flow = + remember(accountViewModel) { + accountViewModel.account.favoriteRelayList.flowNoDefaults + .mapLatest { relays -> + relayUrl in relays + }.onStart { + emit(relayUrl in accountViewModel.account.favoriteRelayList.flowNoDefaults.value) + }.distinctUntilChanged() + .flowOn(Dispatchers.IO) + } + + return flow.collectAsStateWithLifecycle(relayUrl in accountViewModel.account.favoriteRelayList.flowNoDefaults.value) +} + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable fun observeUserIsFollowingChannel( 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 774452173..575306b8c 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 @@ -86,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.VoiceReplyScreen @@ -179,6 +180,7 @@ fun AppNavigation( composableFromEndArgs { ThreadScreen(it.id, accountViewModel, nav) } composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } + composableFromEndArgs { RelayFeedScreen(it, accountViewModel, nav) } composableFromEndArgs { ChessGameScreen(it.gameId, accountViewModel, nav) } composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 02e7f12ed..106dee44f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -187,6 +187,10 @@ sealed class Route { val url: String, ) : Route() + @Serializable data class RelayFeed( + val url: String, + ) : Route() + @Serializable data class EphemeralChat( val id: String, val relayUrl: String, @@ -337,6 +341,7 @@ fun getRouteWithArguments(navController: NavHostController): Route? { dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() 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 a014476d9..4bf0127d4 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 @@ -926,6 +926,10 @@ class AccountViewModel( fun unfollowHashtag(tag: String) = launchSigner { account.unfollowHashtag(tag) } + fun followFavoriteRelay(url: NormalizedRelayUrl) = launchSigner { account.followFavoriteRelay(url) } + + fun unfollowFavoriteRelay(url: NormalizedRelayUrl) = launchSigner { account.unfollowFavoriteRelay(url) } + fun showWord(word: String) = launchSigner { account.showWord(word) } fun hideWord(word: String) = launchSigner { account.hideWord(word) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/NewRelayNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/NewRelayNoteButton.kt new file mode 100644 index 000000000..592aa4cd0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/NewRelayNoteButton.kt @@ -0,0 +1,58 @@ +/* + * 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.relay + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier + +@Composable +fun NewRelayNoteButton( + accountViewModel: AccountViewModel, + nav: INav, +) { + FloatingActionButton( + onClick = { + nav.nav(Route.NewShortNote()) + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + painter = painterRes(R.drawable.ic_compose, 3), + contentDescription = stringRes(id = R.string.new_community_note), + modifier = Size26Modifier, + tint = Color.White, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/RelayFeedScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/RelayFeedScreen.kt new file mode 100644 index 000000000..54ef78f54 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/RelayFeedScreen.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.ui.screen.loggedIn.relay + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingRelay +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.dal.SingleRelayFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource.RelayFeedFilterAssemblerSubscription +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +@Composable +fun RelayFeedScreen( + tag: Route.RelayFeed, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (tag.url.isEmpty()) return + + val normalizedUrl = RelayUrlNormalizer.normalizeOrNull(tag.url) ?: return + + PrepareViewModelsRelayFeedScreen(tag, normalizedUrl, accountViewModel, nav) +} + +@SuppressLint("StateFlowValueCalledInComposition") +@Composable +fun PrepareViewModelsRelayFeedScreen( + tag: Route.RelayFeed, + normalizedUrl: NormalizedRelayUrl, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedViewModel: SingleRelayFeedViewModel = + viewModel( + key = normalizedUrl.url + "SingleRelayFeedViewModel", + factory = + SingleRelayFeedViewModel.Factory( + normalizedUrl, + accountViewModel.account, + ), + ) + + RelayFeedScreen(tag, normalizedUrl, feedViewModel, accountViewModel, nav) +} + +@Composable +fun RelayFeedScreen( + tag: Route.RelayFeed, + normalizedUrl: NormalizedRelayUrl, + feedViewModel: SingleRelayFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(feedViewModel) + RelayFeedFilterAssemblerSubscription(tag, accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + TopBarExtensibleWithBackButton( + title = { + Text(normalizedUrl.url, modifier = Modifier.weight(1f)) + RelayFeedActionOptions(normalizedUrl, accountViewModel) + }, + popBack = nav::popBack, + ) + }, + floatingButton = { + NewRelayNoteButton(accountViewModel, nav) + }, + accountViewModel = accountViewModel, + ) { + Column(Modifier.padding(it)) { + RefresheableFeedView( + feedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +fun RelayFeedActionOptions( + relayUrl: NormalizedRelayUrl, + accountViewModel: AccountViewModel, +) { + val isFollowingRelay by observeUserIsFollowingRelay(relayUrl, accountViewModel) + + if (isFollowingRelay) { + UnfollowButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_unfollow, + ) + } else { + accountViewModel.unfollowFavoriteRelay(relayUrl) + } + } + } else { + FollowButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_follow, + ) + } else { + accountViewModel.followFavoriteRelay(relayUrl) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/dal/RelayFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/dal/RelayFeedFilter.kt new file mode 100644 index 000000000..6e1178995 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/dal/RelayFeedFilter.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.relay.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class RelayFeedFilter( + val relayUrl: NormalizedRelayUrl, + val account: Account, + val cache: LocalCache, +) : AdditiveFeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + relayUrl.url + + override fun feed(): List { + val notes = + cache.notes.filterIntoSet { _, it -> + acceptableEvent(it) + } + + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + private fun innerApplyFilter(collection: Collection): Set = collection.filterTo(HashSet()) { acceptableEvent(it) } + + fun acceptableEvent(it: Note): Boolean = + it.hasRelay(relayUrl) && + !it.isHiddenFor(account.hiddenUsers.flow.value) && + account.isAcceptable(it) + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/dal/SingleRelayFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/dal/SingleRelayFeedViewModel.kt new file mode 100644 index 000000000..ca6549d7a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/dal/SingleRelayFeedViewModel.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.amethyst.ui.screen.loggedIn.relay.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +@Stable +class SingleRelayFeedViewModel( + val relayUrl: NormalizedRelayUrl, + val account: Account, +) : AndroidFeedViewModel( + RelayFeedFilter(relayUrl, account, LocalCache), + ) { + class Factory( + val relayUrl: NormalizedRelayUrl, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = SingleRelayFeedViewModel(relayUrl, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt new file mode 100644 index 000000000..84ac86e8c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.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.relay.datasource + +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.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +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 +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent + +val PostsByRelayKinds = + listOf( + TextNoteEvent.KIND, + ChannelMessageEvent.KIND, + LongTextNoteEvent.KIND, + PollEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + ) + +val PostsByRelayKinds2 = + listOf( + InteractiveStorySceneEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + NipTextEvent.KIND, + PollNoteEvent.KIND, + ) + +fun filterPostsByRelay( + relayUrl: NormalizedRelayUrl, + since: SincePerRelayMap?, +): List { + val sinceTime = since?.get(relayUrl)?.time + return listOf( + RelayBasedFilter( + relay = relayUrl, + filter = + Filter( + kinds = PostsByRelayKinds, + limit = 400, + since = sinceTime, + ), + ), + RelayBasedFilter( + relay = relayUrl, + filter = + Filter( + kinds = PostsByRelayKinds2, + limit = 100, + since = sinceTime, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterAssembler.kt new file mode 100644 index 000000000..a439a9d43 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterAssembler.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.amethyst.ui.screen.loggedIn.relay.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +// This allows multiple screens to be listening to the same or different relay feeds. +class RelayFeedQueryState( + val relayUrl: NormalizedRelayUrl, +) + +class RelayFeedFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + RelayFeedFilterSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterAssemblerSubscription.kt new file mode 100644 index 000000000..8780a35ef --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterAssemblerSubscription.kt @@ -0,0 +1,42 @@ +/* + * 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.relay.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +@Composable +fun RelayFeedFilterAssemblerSubscription( + tag: Route.RelayFeed, + accountViewModel: AccountViewModel, +) { + val state = + remember(tag) { + val normalizedUrl = RelayUrlNormalizer.normalizeOrNull(tag.url) ?: return@remember null + RelayFeedQueryState(normalizedUrl) + } ?: return + + KeyDataSourceSubscription(state, accountViewModel.dataSources().relayFeed) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterSubAssembler.kt new file mode 100644 index 000000000..2a90bd26b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterSubAssembler.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.amethyst.ui.screen.loggedIn.relay.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class RelayFeedFilterSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: RelayFeedQueryState, + since: SincePerRelayMap?, + ): List = filterPostsByRelay(key.relayUrl, since) + + /** + * One key per relay URL. + */ + override fun id(key: RelayFeedQueryState) = key.relayUrl.url +}