Merge pull request #1733 from vitorpamplona/claude/add-relay-filter-screen-aXgh6
Add relay feed screen with filtering and follow/unfollow functionality
This commit is contained in:
@@ -1922,6 +1922,10 @@ class Account(
|
|||||||
|
|
||||||
suspend fun saveFavoriteRelayList(trustedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(favoriteRelayList.saveRelayList(trustedRelays))
|
suspend fun saveFavoriteRelayList(trustedRelays: List<NormalizedRelayUrl>) = 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<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays))
|
suspend fun saveBlockedRelayList(blockedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays))
|
||||||
|
|
||||||
suspend fun sendNip65RelayList(relays: List<AdvertisedRelayInfo>) = sendLiterallyEverywhere(nip65RelayList.saveRelayList(relays))
|
suspend fun sendNip65RelayList(relays: List<AdvertisedRelayInfo>) = sendLiterallyEverywhere(nip65RelayList.saveRelayList(relays))
|
||||||
|
|||||||
+13
@@ -84,6 +84,19 @@ class FavoriteRelayListState(
|
|||||||
emptySet(),
|
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<NormalizedRelayUrl>): FavoriteRelayListEvent {
|
suspend fun saveRelayList(FavoriteRelays: List<NormalizedRelayUrl>): FavoriteRelayListEvent {
|
||||||
val relayListForFavorite = getFavoriteRelayList()
|
val relayListForFavorite = getFavoriteRelayList()
|
||||||
|
|
||||||
|
|||||||
+3
@@ -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.followPacks.feed.datasource.FollowPackFeedFilterAssembler
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler
|
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.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.home.datasource.HomeFilterAssembler
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler
|
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.threadview.datasources.ThreadFilterAssembler
|
||||||
@@ -79,6 +80,7 @@ class RelaySubscriptionsCoordinator(
|
|||||||
val profile = UserProfileFilterAssembler(client)
|
val profile = UserProfileFilterAssembler(client)
|
||||||
val hashtags = HashtagFilterAssembler(client)
|
val hashtags = HashtagFilterAssembler(client)
|
||||||
val geohashes = GeoHashFilterAssembler(client)
|
val geohashes = GeoHashFilterAssembler(client)
|
||||||
|
val relayFeed = RelayFeedFilterAssembler(client)
|
||||||
val followPacks = FollowPackFeedFilterAssembler(client)
|
val followPacks = FollowPackFeedFilterAssembler(client)
|
||||||
val chess = ChessFilterAssembler(client)
|
val chess = ChessFilterAssembler(client)
|
||||||
|
|
||||||
@@ -103,6 +105,7 @@ class RelaySubscriptionsCoordinator(
|
|||||||
profile,
|
profile,
|
||||||
hashtags,
|
hashtags,
|
||||||
geohashes,
|
geohashes,
|
||||||
|
relayFeed,
|
||||||
chess,
|
chess,
|
||||||
nwc,
|
nwc,
|
||||||
)
|
)
|
||||||
|
|||||||
+21
@@ -355,6 +355,27 @@ fun observeUserIsFollowingGeohash(
|
|||||||
return flow.collectAsStateWithLifecycle(geohash in accountViewModel.account.geohashList.flow.value)
|
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<Boolean> {
|
||||||
|
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)
|
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun observeUserIsFollowingChannel(
|
fun observeUserIsFollowingChannel(
|
||||||
|
|||||||
@@ -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.geohash.GeoHashScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen
|
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.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.HomeScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.VoiceReplyScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.VoiceReplyScreen
|
||||||
@@ -179,6 +180,7 @@ fun AppNavigation(
|
|||||||
composableFromEndArgs<Route.Note> { ThreadScreen(it.id, accountViewModel, nav) }
|
composableFromEndArgs<Route.Note> { ThreadScreen(it.id, accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
|
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }
|
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }
|
||||||
|
composableFromEndArgs<Route.RelayFeed> { RelayFeedScreen(it, accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.ChessGame> { ChessGameScreen(it.gameId, accountViewModel, nav) }
|
composableFromEndArgs<Route.ChessGame> { ChessGameScreen(it.gameId, accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.RelayInfo> { RelayInformationScreen(it.url, accountViewModel, nav) }
|
composableFromEndArgs<Route.RelayInfo> { RelayInformationScreen(it.url, accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.Community> { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
composableFromEndArgs<Route.Community> { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||||
|
|||||||
@@ -187,6 +187,10 @@ sealed class Route {
|
|||||||
val url: String,
|
val url: String,
|
||||||
) : Route()
|
) : Route()
|
||||||
|
|
||||||
|
@Serializable data class RelayFeed(
|
||||||
|
val url: String,
|
||||||
|
) : Route()
|
||||||
|
|
||||||
@Serializable data class EphemeralChat(
|
@Serializable data class EphemeralChat(
|
||||||
val id: String,
|
val id: String,
|
||||||
val relayUrl: String,
|
val relayUrl: String,
|
||||||
@@ -337,6 +341,7 @@ fun getRouteWithArguments(navController: NavHostController): Route? {
|
|||||||
dest.hasRoute<Route.Community>() -> entry.toRoute<Route.Community>()
|
dest.hasRoute<Route.Community>() -> entry.toRoute<Route.Community>()
|
||||||
dest.hasRoute<Route.QRDisplay>() -> entry.toRoute<Route.QRDisplay>()
|
dest.hasRoute<Route.QRDisplay>() -> entry.toRoute<Route.QRDisplay>()
|
||||||
dest.hasRoute<Route.RelayInfo>() -> entry.toRoute<Route.RelayInfo>()
|
dest.hasRoute<Route.RelayInfo>() -> entry.toRoute<Route.RelayInfo>()
|
||||||
|
dest.hasRoute<Route.RelayFeed>() -> entry.toRoute<Route.RelayFeed>()
|
||||||
dest.hasRoute<Route.RoomByAuthor>() -> entry.toRoute<Route.RoomByAuthor>()
|
dest.hasRoute<Route.RoomByAuthor>() -> entry.toRoute<Route.RoomByAuthor>()
|
||||||
dest.hasRoute<Route.PublicChatChannel>() -> entry.toRoute<Route.PublicChatChannel>()
|
dest.hasRoute<Route.PublicChatChannel>() -> entry.toRoute<Route.PublicChatChannel>()
|
||||||
dest.hasRoute<Route.LiveActivityChannel>() -> entry.toRoute<Route.LiveActivityChannel>()
|
dest.hasRoute<Route.LiveActivityChannel>() -> entry.toRoute<Route.LiveActivityChannel>()
|
||||||
|
|||||||
+4
@@ -926,6 +926,10 @@ class AccountViewModel(
|
|||||||
|
|
||||||
fun unfollowHashtag(tag: String) = launchSigner { account.unfollowHashtag(tag) }
|
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 showWord(word: String) = launchSigner { account.showWord(word) }
|
||||||
|
|
||||||
fun hideWord(word: String) = launchSigner { account.hideWord(word) }
|
fun hideWord(word: String) = launchSigner { account.hideWord(word) }
|
||||||
|
|||||||
+58
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+149
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -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<Note>() {
|
||||||
|
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + relayUrl.url
|
||||||
|
|
||||||
|
override fun feed(): List<Note> {
|
||||||
|
val notes =
|
||||||
|
cache.notes.filterIntoSet { _, it ->
|
||||||
|
acceptableEvent(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sort(notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
|
||||||
|
|
||||||
|
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> = 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<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||||
|
}
|
||||||
+45
@@ -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 <T : ViewModel> create(modelClass: Class<T>): T = SingleRelayFeedViewModel(relayUrl, account) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -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<RelayBasedFilter> {
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
+45
@@ -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<RelayFeedQueryState>() {
|
||||||
|
val group =
|
||||||
|
listOf(
|
||||||
|
RelayFeedFilterSubAssembler(client, ::allKeys),
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun invalidateKeys() = invalidateFilters()
|
||||||
|
|
||||||
|
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||||
|
|
||||||
|
override fun destroy() = group.forEach { it.destroy() }
|
||||||
|
}
|
||||||
+42
@@ -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)
|
||||||
|
}
|
||||||
+41
@@ -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<RelayFeedQueryState>,
|
||||||
|
) : PerUniqueIdEoseManager<RelayFeedQueryState, String>(client, allKeys) {
|
||||||
|
override fun updateFilter(
|
||||||
|
key: RelayFeedQueryState,
|
||||||
|
since: SincePerRelayMap?,
|
||||||
|
): List<RelayBasedFilter> = filterPostsByRelay(key.relayUrl, since)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One key per relay URL.
|
||||||
|
*/
|
||||||
|
override fun id(key: RelayFeedQueryState) = key.relayUrl.url
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user