Updates the User Profile's Relay List to an outbox version

This commit is contained in:
Vitor Pamplona
2025-08-30 13:49:24 -04:00
parent 9d4c690760
commit dd5ea51575
9 changed files with 160 additions and 129 deletions

View File

@@ -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)
}
}

View File

@@ -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()
}

View File

@@ -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<Boolean> {
// 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<NormalizedRelayUrl> = emptyList(),
val userRelayList: List<NormalizedRelayUrl> = emptyList(),
)
@OptIn(FlowPreview::class)
@Composable
fun observeUserRelaysUsing(
user: User,
accountViewModel: AccountViewModel,
): State<RelayUsage> {
// 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())
}

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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)
}
}

View File

@@ -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<List<RelayInfo>>(emptyList())
val feedContent = _feedContent.asStateFlow()
var currentUser: MutableStateFlow<User?> = MutableStateFlow(null)
var currentUser: User? = null
var currentJob: Job? = null
fun convert(
relays: Set<NormalizedRelayUrl>?,
user: User?,
): List<RelayInfo> {
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<RelayInfo>())
}
}.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<RelayInfo>())
}
}.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<RelayInfo>())
}
}.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<Boolean> = 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<NormalizedRelayUrl, RelayInfo>,
relays: Map<NormalizedRelayUrl, ReadWrite>?,
): List<RelayInfo> {
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()
}
}

View File

@@ -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 "--"

View File

@@ -1084,10 +1084,13 @@
<string name="dm_upload">DM Upload</string>
<string name="relay_settings">Relay Settings</string>
<string name="public_home_section">Public Outbox/Home Relays</string>
<string name="public_home_section_explainer_profile">User is posting his content to these relays</string>
<string name="public_home_section_explainer">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 13 relays. They can be personal relays, paid relays or public relays.</string>
<string name="public_notif_section">Public Inbox Relays</string>
<string name="public_notif_section_explainer_profile">User is receiving notifications on these relays</string>
<string name="public_notif_section_explainer">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 13 relays.</string>
<string name="private_inbox_section">DM Inbox Relays</string>
<string name="private_inbox_section_explainer_profile">User receives DMs on these relays</string>
<string name="private_inbox_section_explainer">Insert between 13 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)</string>
<string name="private_outbox_section">Private Home Relays</string>
<string name="private_outbox_section_explainer">Insert between 13 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.</string>