* 'main' of https://github.com/vitorpamplona/amethyst:
  feat: add RelayFeedScreen for browsing and following relay-specific feeds
This commit is contained in:
Vitor Pamplona
2026-03-02 09:31:39 -05:00
15 changed files with 577 additions and 0 deletions
@@ -1922,6 +1922,10 @@ class Account(
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 sendNip65RelayList(relays: List<AdvertisedRelayInfo>) = sendLiterallyEverywhere(nip65RelayList.saveRelayList(relays))
@@ -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<NormalizedRelayUrl>): FavoriteRelayListEvent {
val relayListForFavorite = getFavoriteRelayList()
@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashF
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource.RelayFeedFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -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,
)
@@ -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<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)
@Composable
fun observeUserIsFollowingChannel(
@@ -101,6 +101,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScree
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
@@ -179,6 +180,7 @@ fun AppNavigation(
composableFromEndArgs<Route.Note> { ThreadScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.Hashtag> { HashtagScreen(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.RelayInfo> { RelayInformationScreen(it.url, 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,
) : 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<Route.Community>() -> entry.toRoute<Route.Community>()
dest.hasRoute<Route.QRDisplay>() -> entry.toRoute<Route.QRDisplay>()
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.PublicChatChannel>() -> entry.toRoute<Route.PublicChatChannel>()
dest.hasRoute<Route.LiveActivityChannel>() -> entry.toRoute<Route.LiveActivityChannel>()
@@ -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) }
@@ -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,
)
}
}
@@ -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)
}
}
}
}
@@ -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)
}
@@ -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
}
}
@@ -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,
),
),
)
}
@@ -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() }
}
@@ -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)
}
@@ -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
}