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:
+72
-111
@@ -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()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user