From dd5ea5157518a8ba224dc2f2c3cb5fe6c6ad7584 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 30 Aug 2025 13:49:24 -0400 Subject: [PATCH] Updates the User Profile's Relay List to an outbox version --- .../amethyst/model/LocalCache.kt | 4 +- .../com/vitorpamplona/amethyst/model/User.kt | 16 +- .../reqCommand/user/UserObservers.kt | 52 +----- .../amethyst/ui/note/RelayCompose.kt | 3 +- .../amethyst/ui/note/types/RelayList.kt | 2 +- .../loggedIn/profile/relays/RelayFeedView.kt | 43 ++++- .../profile/relays/RelayFeedViewModel.kt | 159 +++++++++++------- .../profile/relays/RelaysTabHeader.kt | 7 +- amethyst/src/main/res/values/strings.xml | 3 + 9 files changed, 160 insertions(+), 129 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 55991da90..5ec0d7d80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -309,7 +309,9 @@ object LocalCache : ILocalCache { require(isValidHex(key = key)) { "$key is not a valid hex" } return users.getOrCreate(key) { - User(it) + val nip65RelayListNote = getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(key)) + val dmRelayListNote = getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(key)) + User(it, nip65RelayListNote, dmRelayListNote) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt index eca19a58a..a91dd6e05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -50,6 +50,8 @@ import java.math.BigDecimal @Stable class User( val pubkeyHex: String, + val nip65RelayListNote: Note, + val dmRelayListNote: Note, ) { var info: UserMetadata? = null @@ -72,9 +74,9 @@ class User( fun pubkeyDisplayHex() = pubkeyNpub().toShortDisplay() - fun dmInboxRelayList() = (LocalCache.getAddressableNoteIfExists(ChatMessageRelayListEvent.createAddressTag(pubkeyHex))?.event as? ChatMessageRelayListEvent) + fun dmInboxRelayList() = dmRelayListNote.event as? ChatMessageRelayListEvent - fun authorRelayList() = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(pubkeyHex))?.event as? AdvertisedRelayListEvent) + fun authorRelayList() = nip65RelayListNote.event as? AdvertisedRelayListEvent fun toNProfile() = NProfile.create(pubkeyHex, relayHints()) @@ -139,8 +141,6 @@ class User( ?.followers ?.invalidateData() } - - flowSet?.relays?.invalidateData() } fun addReport(note: Note) { @@ -227,7 +227,7 @@ class User( here.counter++ } - flowSet?.relayInfo?.invalidateData() + flowSet?.usedRelays?.invalidateData() } fun updateUserInfo( @@ -339,20 +339,18 @@ class UserFlowSet( // Observers line up here. val metadata = UserBundledRefresherFlow(u) val follows = UserBundledRefresherFlow(u) - val relays = UserBundledRefresherFlow(u) val followers = UserBundledRefresherFlow(u) val reports = UserBundledRefresherFlow(u) - val relayInfo = UserBundledRefresherFlow(u) + val usedRelays = UserBundledRefresherFlow(u) val zaps = UserBundledRefresherFlow(u) val statuses = UserBundledRefresherFlow(u) fun isInUse(): Boolean = metadata.hasObservers() || - relays.hasObservers() || follows.hasObservers() || followers.hasObservers() || reports.hasObservers() || - relayInfo.hasObservers() || + usedRelays.hasObservers() || zaps.hasObservers() || statuses.hasObservers() } 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 812fc9e84..7430f3489 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 @@ -43,7 +43,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapLatest @@ -634,61 +633,18 @@ fun observeUserStatuses( @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable fun observeUserRelayIntoList( - user: User, relayUrl: NormalizedRelayUrl, accountViewModel: AccountViewModel, ): State { - // Subscribe in the relay for changes in the metadata of this user. - UserFinderFilterAssemblerSubscription(user, accountViewModel) - // Subscribe in the LocalCache for changes that arrive in the device val flow = - remember(user) { - user - .flow() - .relayInfo - .stateFlow - .sample(1000) - .mapLatest { userState -> - userState.user.latestContactList - ?.relays() - ?.none { it.key == relayUrl } == true + remember(accountViewModel) { + accountViewModel.account.trustedRelays.flow + .mapLatest { relays -> + relayUrl in relays }.distinctUntilChanged() .flowOn(Dispatchers.Default) } return flow.collectAsStateWithLifecycle(false) } - -data class RelayUsage( - val relays: List = emptyList(), - val userRelayList: List = emptyList(), -) - -@OptIn(FlowPreview::class) -@Composable -fun observeUserRelaysUsing( - user: User, - accountViewModel: AccountViewModel, -): State { - // Subscribe in the relay for changes in the metadata of this user. - UserFinderFilterAssemblerSubscription(user, accountViewModel) - - // Subscribe in the LocalCache for changes that arrive in the device - val flow = - remember(user) { - combine(user.flow().relays.stateFlow, user.flow().relayInfo.stateFlow) { relays, relayInfo -> - val userRelaysBeingUsed = relays.user.relaysBeingUsed.map { it.key } - val currentUserRelays = - relayInfo.user.latestContactList - ?.relays() - ?.map { it.key } ?: emptyList() - - RelayUsage(userRelaysBeingUsed, currentUserRelays) - }.sample(1000) - .distinctUntilChanged() - .flowOn(Dispatchers.Default) - } - - return flow.collectAsStateWithLifecycle(RelayUsage()) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt index 45ff9323d..7b21446da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonPadding +import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @@ -67,7 +68,7 @@ fun RelayCompose( ) { Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.spacedBy(Size5dp), ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt index f38e18a30..df050701a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt @@ -388,7 +388,7 @@ private fun RelayOptionsAction( accountViewModel: AccountViewModel, nav: INav, ) { - val isCurrentlyOnTheUsersList by observeUserRelayIntoList(accountViewModel.userProfile(), relay, accountViewModel) + val isCurrentlyOnTheUsersList by observeUserRelayIntoList(relay, accountViewModel) val clipboardManager = LocalClipboardManager.current if (isCurrentlyOnTheUsersList) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt index af1206d85..7b27e87df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt @@ -20,23 +20,29 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.RelayInfo import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.RelayCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.FeedPadding @Composable fun RelayFeedView( @@ -45,16 +51,45 @@ fun RelayFeedView( nav: INav, enablePullRefresh: Boolean = true, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val outboxListState by viewModel.nip65OutboxFlow.collectAsStateWithLifecycle() + val inboxListState by viewModel.nip65InboxFlow.collectAsStateWithLifecycle() + val dmListState by viewModel.dmInboxFlow.collectAsStateWithLifecycle() RefresheableBox(viewModel, enablePullRefresh) { val listState = rememberLazyListState() LazyColumn( - contentPadding = FeedPadding, + contentPadding = PaddingValues(top = 10.dp, bottom = 20.dp), state = listState, ) { - itemsIndexed(feedState, key = { _, item -> item.url.url }) { _, item -> + item { + SettingsCategory( + stringRes(R.string.public_home_section), + stringRes(R.string.public_home_section_explainer_profile), + Modifier.padding(top = 10.dp, bottom = 8.dp, start = 10.dp, end = 10.dp), + ) + } + itemsIndexed(outboxListState, key = { _, item -> "outbox" + item.url.url }) { _, item -> + RenderRelayRow(item, accountViewModel, nav) + } + item { + SettingsCategory( + stringRes(R.string.public_notif_section), + stringRes(R.string.public_notif_section_explainer_profile), + Modifier.padding(top = 24.dp, bottom = 8.dp, start = 10.dp, end = 10.dp), + ) + } + itemsIndexed(inboxListState, key = { _, item -> "inbox" + item.url.url }) { _, item -> + RenderRelayRow(item, accountViewModel, nav) + } + item { + SettingsCategory( + stringRes(R.string.private_inbox_section), + stringRes(R.string.private_inbox_section_explainer_profile), + Modifier.padding(top = 24.dp, bottom = 8.dp, start = 10.dp, end = 10.dp), + ) + } + itemsIndexed(dmListState, key = { _, item -> "dminbox" + item.url.url }) { _, item -> RenderRelayRow(item, accountViewModel, nav) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt index 7c8333adc..2ebbcd3fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt @@ -29,18 +29,24 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.RelayInfo import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent -import com.vitorpamplona.ammolite.relays.BundledUpdate import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip02FollowList.ReadWrite +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch +import kotlin.collections.map @Stable class RelayFeedViewModel : @@ -51,11 +57,90 @@ class RelayFeedViewModel : .thenByDescending { it.counter } .thenBy { it.url.url } - private val _feedContent = MutableStateFlow>(emptyList()) - val feedContent = _feedContent.asStateFlow() + var currentUser: MutableStateFlow = MutableStateFlow(null) - var currentUser: User? = null - var currentJob: Job? = null + fun convert( + relays: Set?, + user: User?, + ): List { + if (relays == null || user == null) return emptyList() + return relays + .map { relay -> + user.relaysBeingUsed[relay] ?: RelayInfo(relay, 0, 0) + }.sortedWith(order) + } + + @OptIn(ExperimentalCoroutinesApi::class) + val nip65OutboxFlow = + currentUser + .transformLatest { user -> + if (user != null) { + emitAll( + combine( + user.nip65RelayListNote + .flow() + .metadata.stateFlow, + user.flow().usedRelays.stateFlow, + ) { nip65, userState -> + val relays = (nip65.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()?.toSet() ?: emptySet() + convert(relays, userState.user) + }, + ) + } else { + emit(emptyList()) + } + }.onStart { + emit(convert((currentUser.value?.nip65RelayListNote?.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()?.toSet(), currentUser.value)) + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + @OptIn(ExperimentalCoroutinesApi::class) + val nip65InboxFlow = + currentUser + .transformLatest { user -> + if (user != null) { + emitAll( + combine( + user.nip65RelayListNote + .flow() + .metadata.stateFlow, + user.flow().usedRelays.stateFlow, + ) { nip65, userState -> + val relays = (nip65.note.event as? AdvertisedRelayListEvent)?.readRelaysNorm()?.toSet() ?: emptySet() + convert(relays, userState.user) + }, + ) + } else { + emit(emptyList()) + } + }.onStart { + emit(convert((currentUser.value?.nip65RelayListNote?.event as? AdvertisedRelayListEvent)?.readRelaysNorm()?.toSet(), currentUser.value)) + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + @OptIn(ExperimentalCoroutinesApi::class) + val dmInboxFlow = + currentUser + .transformLatest { user -> + if (user != null) { + emitAll( + combine( + user.dmRelayListNote + .flow() + .metadata.stateFlow, + user.flow().usedRelays.stateFlow, + ) { nip65, userState -> + val relays = (nip65.note.event as? ChatMessageRelayListEvent)?.relays()?.toSet() ?: emptySet() + convert(relays, userState.user) + }, + ) + } else { + emit(emptyList()) + } + }.onStart { + emit(convert((currentUser.value?.nip65RelayListNote?.event as? ChatMessageRelayListEvent)?.relays()?.toSet(), currentUser.value)) + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) override val isRefreshing: MutableState = mutableStateOf(false) @@ -65,80 +150,36 @@ class RelayFeedViewModel : } } - fun refreshSuspended() { + suspend fun refreshSuspended() { try { isRefreshing.value = true - currentUser?.let { - val newList = mergeRelays(it.relaysBeingUsed, it.latestContactList?.relays()) - _feedContent.update { newList } - } + delay(1000) } finally { isRefreshing.value = false } } - fun mergeRelays( - relaysBeingUsed: Map, - relays: Map?, - ): List { - val userRelaysBeingUsed = relaysBeingUsed.map { it.value } - - val currentUserRelays = - relays?.mapNotNull { - val url = it.key - if (url !in relaysBeingUsed) { - RelayInfo(url, 0, 0) - } else { - null - } - } ?: emptyList() - - return (userRelaysBeingUsed + currentUserRelays).sortedWith(order) - } - @OptIn(FlowPreview::class) fun subscribeTo(user: User) { if (currentUser != user) { - currentUser = user - - currentJob?.cancel() - currentJob = - viewModelScope.launch { - combine(currentUser!!.flow().relays.stateFlow, currentUser!!.flow().relayInfo.stateFlow) { relays, relayInfo -> - mergeRelays(relays.user.relaysBeingUsed, relayInfo.user.latestContactList?.relays()) - }.debounce(1000) - .collect { newList -> - _feedContent.update { newList } - } - } - - invalidateData() + currentUser.tryEmit(user) } } fun unsubscribeTo(user: User) { if (currentUser == user) { - currentUser = null - currentJob?.cancel() + currentUser.tryEmit(null) invalidateData() } } - private val bundler = BundledUpdate(250, Dispatchers.IO) - override fun invalidateData(ignoreIfDoing: Boolean) { - bundler.invalidate(ignoreIfDoing) { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - refreshSuspended() - } + currentUser.tryEmit(currentUser.value) } override fun onCleared() { Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - bundler.cancel() - currentJob?.cancel() super.onCleared() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelaysTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelaysTabHeader.kt index 158d1be4c..58ade6e62 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelaysTabHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelaysTabHeader.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserRelaysUsing import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -34,9 +33,5 @@ fun RelaysTabHeader( baseUser: User, accountViewModel: AccountViewModel, ) { - val userState by observeUserRelaysUsing(baseUser, accountViewModel) - - Text(text = "${sizeAsString(userState.userRelayList.size)} / ${sizeAsString(userState.relays.size)} ${stringRes(R.string.relays)}") + Text(text = stringRes(R.string.relays)) } - -private fun sizeAsString(count: Int) = if (count > 0) count.toString() else "--" diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c1082ba9f..b5ad37fbb 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1084,10 +1084,13 @@ DM Upload Relay Settings Public Outbox/Home Relays + User is posting his content to these relays This relay type stores all your content. Amethyst will send your posts here and others will use these relays to find your content. Insert between 1–3 relays. They can be personal relays, paid relays or public relays. Public Inbox Relays + User is receiving notifications on these relays This relay type receives all replies, comments, likes and zaps to your posts. They can be paid or free relays. Limits set by the relay operator can limit the notifications you receive for the good and for the bad. For example, if you are being attacked by comment spam, paid relays can filter the spam out. Insert between 1–3 relays. DM Inbox Relays + User receives DMs on these relays Insert between 1–3 relays to serve as your private inbox. Others will use these relays to send DMs to you. DM Inbox relays should accept any message from anyone, but only allow you to download them. Good options are:\n - inbox.nostr.wine (paid)\n - auth.nostr1.com (free)\n - you.nostr1.com (personal relays - paid) Private Home Relays Insert between 1–3 relays to store events no one else can see, like your Drafts and/or app settings. Ideally, these relays are either local or require authentication before downloading each user\'s content.