feat(cache): UserProfileScreen notes feed → DesktopFeedViewModel

Replace EventCollectionState + createUserPostsSubscription + manual cache
hydration with DesktopFeedViewModel + DesktopProfileFeedFilter. Profile
header subscriptions (metadata, contact list, followers) kept as-is since
they're not feed concerns. DisposableEffect cleanup on profile switch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-23 08:57:55 +02:00
parent c63ace1d9c
commit 50eb03bc3e
@@ -59,6 +59,7 @@ import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -74,24 +75,24 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction
import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastBanner import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastBanner
import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.state.FollowState import com.vitorpamplona.amethyst.commons.state.FollowState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.feeds.DesktopProfileFeedFilter
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createUserPostsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.profile.GalleryTab import com.vitorpamplona.amethyst.desktop.ui.profile.GalleryTab
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
@@ -141,19 +142,25 @@ fun UserProfileScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// User's posts // User's posts — cache-backed via DesktopFeedViewModel
val eventState = val profileViewModel =
remember { remember(pubKeyHex) {
EventCollectionState<Event>( DesktopFeedViewModel(
getId = { it.id }, DesktopProfileFeedFilter(pubKeyHex, localCache),
sortComparator = compareByDescending { it.createdAt }, localCache,
maxSize = 200,
scope = scope,
) )
} }
val events by eventState.items.collectAsState() DisposableEffect(profileViewModel) {
var postsLoading by remember { mutableStateOf(true) } onDispose { profileViewModel.destroy() }
var postsError by remember { mutableStateOf<String?>(null) } }
val profileFeedState by profileViewModel.feedState.feedContent.collectAsState()
val profileLoadedNotes =
if (profileFeedState is FeedState.Loaded) {
val loaded by (profileFeedState as FeedState.Loaded).feed.collectAsState()
loaded.list
} else {
kotlinx.collections.immutable.persistentListOf()
}
var retryTrigger by remember { mutableStateOf(0) } var retryTrigger by remember { mutableStateOf(0) }
// Tab and gallery state // Tab and gallery state
@@ -206,26 +213,6 @@ fun UserProfileScreen(
} }
} }
// Clear posts when profile changes, then hydrate from cache
remember(pubKeyHex, retryTrigger) {
eventState.clear()
postsLoading = true
postsError = null
}
// Hydrate from cache — show previously loaded posts instantly
LaunchedEffect(pubKeyHex) {
val cachedNotes =
localCache.notes.filterIntoSet { _, note ->
note.event?.kind == 1 && note.author?.pubkeyHex == pubKeyHex
}
if (cachedNotes.isNotEmpty()) {
val events = cachedNotes.mapNotNull { it.event }
eventState.addItems(events)
postsLoading = false
}
}
// Subscribe to user metadata // Subscribe to user metadata
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) { if (connectedRelays.isNotEmpty()) {
@@ -313,29 +300,6 @@ fun UserProfileScreen(
} }
} }
// Subscribe to user posts
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
postsLoading = true
postsError = null
createUserPostsSubscription(
relays = connectedRelays,
pubKeyHex = pubKeyHex,
onEvent = { event, _, relay, _ ->
subscriptionsCoordinator?.consumeEvent(event, relay)
eventState.addItem(event)
},
onEose = { _, _ ->
postsLoading = false
},
)
} else {
postsLoading = false
postsError = "No relays configured"
null
}
}
// Subscribe to picture events (kind 20) for gallery tab // Subscribe to picture events (kind 20) for gallery tab
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) { if (connectedRelays.isNotEmpty()) {
@@ -722,35 +686,8 @@ fun UserProfileScreen(
// Tab content // Tab content
when (selectedTab) { when (selectedTab) {
0 -> { 0 -> {
when { when (profileFeedState) {
postsError != null -> { is FeedState.Loading -> {
item(key = "error") {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"Failed to load posts",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(8.dp))
Text(
postsError!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
OutlinedButton(onClick = { retryTrigger++ }) {
Text("Retry")
}
}
}
}
}
postsLoading -> {
item(key = "loading") { item(key = "loading") {
Box( Box(
modifier = Modifier.fillMaxWidth().padding(32.dp), modifier = Modifier.fillMaxWidth().padding(32.dp),
@@ -769,7 +706,7 @@ fun UserProfileScreen(
} }
} }
events.isEmpty() -> { is FeedState.Empty -> {
item(key = "empty") { item(key = "empty") {
Box( Box(
modifier = Modifier.fillMaxWidth().padding(32.dp), modifier = Modifier.fillMaxWidth().padding(32.dp),
@@ -784,33 +721,57 @@ fun UserProfileScreen(
} }
} }
else -> { is FeedState.FeedError -> {
items(events.distinctBy { it.id }, key = { it.id }) { event -> item(key = "error") {
// Look up Note from cache for the new FeedNoteCard API Box(
val note = localCache.getNoteIfExists(event.id) modifier = Modifier.fillMaxWidth().padding(32.dp),
if (note != null) { contentAlignment = Alignment.Center,
FeedNoteCard( ) {
note = note, Column(horizontalAlignment = Alignment.CenterHorizontally) {
relayManager = relayManager, Text(
localCache = localCache, "Failed to load posts",
account = account, style = MaterialTheme.typography.titleMedium,
nwcConnection = nwcConnection, color = MaterialTheme.colorScheme.error,
onReply = onCompose, )
onZapFeedback = onZapFeedback, Spacer(Modifier.height(8.dp))
onNavigateToProfile = onNavigateToProfile, Text(
onImageClick = { urls, index -> (profileFeedState as FeedState.FeedError).errorMessage,
lightboxState = LightboxState(urls, index) style = MaterialTheme.typography.bodySmall,
}, color = MaterialTheme.colorScheme.onSurfaceVariant,
onMediaClick = { urls, index, seekPos -> )
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer Spacer(Modifier.height(16.dp))
.playVideo(urls[index], seekPos) OutlinedButton(onClick = { retryTrigger++ }) {
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer Text("Retry")
.toggleFullscreen() }
}, }
)
} }
} }
} }
is FeedState.Loaded -> {
// loadedNotes collected outside LazyColumn in profileLoadedNotes
items(profileLoadedNotes, key = { it.idHex }) { note ->
FeedNoteCard(
note = note,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReply = onCompose,
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
onImageClick = { urls, index ->
lightboxState = LightboxState(urls, index)
},
onMediaClick = { urls, index, seekPos ->
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.playVideo(urls[index], seekPos)
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.toggleFullscreen()
},
)
}
}
} }
} }