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 55991da90c..5ec0d7d804 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 eca19a58a7..a91dd6e053 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 812fc9e84a..7430f34898 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 45ff9323d5..7b21446da7 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 f38e18a308..df050701af 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 af1206d858..7b27e87dfa 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 7c8333adc5..2ebbcd3fef 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 158d1be4cd..58ade6e622 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 c1082ba9f6..b5ad37fbbc 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.