From 50eb03bc3ed4ea9131313aa37f5a7bc081ef1d22 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 23 Mar 2026 08:57:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(cache):=20UserProfileScreen=20notes=20feed?= =?UTF-8?q?=20=E2=86=92=20DesktopFeedViewModel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../amethyst/desktop/ui/UserProfileScreen.kt | 183 +++++++----------- 1 file changed, 72 insertions(+), 111 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 36b769f0e..79dce41fb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -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( - 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(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() + }, + ) + } + } } }