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.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.profile.ProfileBroadcastBanner
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.ui.components.LoadingState
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.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.feeds.DesktopProfileFeedFilter
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
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.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
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.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
@@ -141,19 +142,25 @@ fun UserProfileScreen(
val scope = rememberCoroutineScope()
// User's posts
val eventState =
remember {
EventCollectionState<Event>(
getId = { it.id },
sortComparator = compareByDescending { it.createdAt },
maxSize = 200,
scope = scope,
// User's posts — cache-backed via DesktopFeedViewModel
val profileViewModel =
remember(pubKeyHex) {
DesktopFeedViewModel(
DesktopProfileFeedFilter(pubKeyHex, localCache),
localCache,
)
}
val events by eventState.items.collectAsState()
var postsLoading by remember { mutableStateOf(true) }
var postsError by remember { mutableStateOf<String?>(null) }
DisposableEffect(profileViewModel) {
onDispose { profileViewModel.destroy() }
}
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) }
// 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
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
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
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
@@ -722,35 +686,8 @@ fun UserProfileScreen(
// Tab content
when (selectedTab) {
0 -> {
when {
postsError != null -> {
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 -> {
when (profileFeedState) {
is FeedState.Loading -> {
item(key = "loading") {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
@@ -769,7 +706,7 @@ fun UserProfileScreen(
}
}
events.isEmpty() -> {
is FeedState.Empty -> {
item(key = "empty") {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
@@ -784,33 +721,57 @@ fun UserProfileScreen(
}
}
else -> {
items(events.distinctBy { it.id }, key = { it.id }) { event ->
// Look up Note from cache for the new FeedNoteCard API
val note = localCache.getNoteIfExists(event.id)
if (note != null) {
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()
},
)
is FeedState.FeedError -> {
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(
(profileFeedState as FeedState.FeedError).errorMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
OutlinedButton(onClick = { retryTrigger++ }) {
Text("Retry")
}
}
}
}
}
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()
},
)
}
}
}
}