feat(cache): FeedScreen → DesktopFeedViewModel + Note-based FeedNoteCard

Rewrite FeedScreen from inline EventCollectionState + 8 rememberSubscription()
calls to cache-centric DesktopFeedViewModel pattern:

- ViewModel keyed on feedMode with DisposableEffect cleanup (destroy())
- FeedState pattern matching (Loading/Loaded/Empty/Error)
- FeedNoteCard takes Note instead of Event, reads counts from Note.flowSet
- DisposableEffect for note.clearFlow() cleanup in LazyColumn
- DisposableEffect for Coordinator interaction subscription lifecycle
- LaunchedEffect for rate-limited metadata loading via Coordinator
- Extract FeedHeader into separate composable
- Add destroy() to DesktopFeedViewModel (ViewModel.clear() is internal in KMP)
- Update UserProfileScreen to look up Note from cache for FeedNoteCard

Removes ~350 lines of inline state management (EventCollectionState,
zapsByEvent, reactionIdsByEvent, replyIdsByEvent, repostIdsByEvent,
followedUsers, bookmarkList, 8 rememberSubscription blocks).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-23 08:32:59 +02:00
parent e8ede21c17
commit c63ace1d9c
3 changed files with 263 additions and 515 deletions
@@ -45,49 +45,32 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter
import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createBatchMetadataSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
import com.vitorpamplona.amethyst.desktop.ui.note.extractMentionedPubkeys
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
data class LightboxState(
val urls: List<String>,
@@ -97,11 +80,12 @@ data class LightboxState(
)
/**
* Note card with action buttons.
* Note card that reads counts from the Note model (cache-backed).
* Event is extracted from Note for signing operations in NoteActionsRow.
*/
@Composable
fun FeedNoteCard(
event: Event,
note: Note,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn?,
@@ -112,15 +96,25 @@ fun FeedNoteCard(
onNavigateToThread: (String) -> Unit = {},
onImageClick: ((List<String>, Int) -> Unit)? = null,
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
zapReceipts: List<ZapReceipt> = emptyList(),
reactionCount: Int = 0,
replyCount: Int = 0,
repostCount: Int = 0,
bookmarkList: BookmarkListEvent? = null,
isBookmarked: Boolean = false,
onBookmarkChanged: (BookmarkListEvent) -> Unit = {},
) {
val zapAmountSats = zapReceipts.sumOf { it.amountSats }
val event = note.event ?: return
// Observe Note.flowSet for live count updates
val flowSet = remember(note) { note.flow() }
val reactionsState by flowSet.reactions.stateFlow.collectAsState()
val repliesState by flowSet.replies.stateFlow.collectAsState()
val zapsState by flowSet.zaps.stateFlow.collectAsState()
// Read counts from Note model (re-read on each stateFlow emission)
val reactionCount = note.countReactions()
val replyCount = note.replies.size
val repostCount = note.boosts.size
val zapAmount = note.zapsAmount
// Clean up flowSet when card leaves composition
DisposableEffect(note) {
onDispose { note.clearFlow() }
}
Column {
NoteCard(
@@ -144,15 +138,12 @@ fun FeedNoteCard(
onReplyClick = onReply,
onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
zapCount = zapReceipts.size,
zapAmountSats = zapAmountSats,
zapReceipts = zapReceipts,
zapCount = note.zaps.size,
zapAmountSats = zapAmount.toLong(),
zapReceipts = emptyList(), // TODO: extract ZapReceipts from Note.zaps
reactionCount = reactionCount,
replyCount = replyCount,
repostCount = repostCount,
bookmarkList = bookmarkList,
isBookmarked = isBookmarked,
onBookmarkChanged = onBookmarkChanged,
)
}
}
@@ -172,488 +163,146 @@ fun FeedScreen(
onZapFeedback: (ZapFeedback) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
// Configured relay URLs only — stabilized with distinctUntilChanged() to prevent
// subscription churn from relay status changes (pings, connect/disconnect).
// openReqSubscription connects relays on demand; no need to wait for connectedRelays.
val configuredRelays by remember {
relayManager.relayStatuses
.map { it.keys }
.distinctUntilChanged()
}.collectAsState(emptySet())
val scope = rememberCoroutineScope()
val eventState =
remember {
EventCollectionState<Event>(
getId = { it.id },
sortComparator = compareByDescending { it.createdAt },
maxSize = 200,
scope = scope,
)
}
val events by eventState.items.collectAsState()
val followedUsers by localCache.followedUsers.collectAsState()
var replyToEvent by remember { mutableStateOf<Event?>(null) }
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
var zapsByEvent by remember { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) }
// Track reaction event IDs per target event to deduplicate
var reactionIdsByEvent by remember { mutableStateOf<Map<String, Set<String>>>(emptyMap()) }
val reactionsByEvent = reactionIdsByEvent.mapValues { it.value.size }
// Track reply/repost event IDs per target event to deduplicate
var replyIdsByEvent by remember { mutableStateOf<Map<String, Set<String>>>(emptyMap()) }
val repliesByEvent = replyIdsByEvent.mapValues { it.value.size }
var repostIdsByEvent by remember { mutableStateOf<Map<String, Set<String>>>(emptyMap()) }
val repostsByEvent = repostIdsByEvent.mapValues { it.value.size }
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
var bookmarkedEventIds by remember { mutableStateOf<Set<String>>(emptySet()) }
// Track EOSE to know when initial load is complete
var eoseReceivedCount by remember { mutableStateOf(0) }
val initialLoadComplete = eoseReceivedCount > 0
// Load followed users for Following feed mode
rememberSubscription(configuredRelays, account, feedMode, relayManager = relayManager) {
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
createContactListSubscription(
relays = configuredRelays,
pubKeyHex = account.pubKeyHex,
onEvent = { event, _, relay, _ ->
if (event is ContactListEvent) {
val follows = event.verifiedFollowKeySet()
followedUsers = follows
// DesktopFeedViewModel keyed on feedMode — recreated on mode switch
val viewModel =
remember(feedMode) {
val filter =
when (feedMode) {
FeedMode.GLOBAL -> {
DesktopGlobalFeedFilter(localCache)
}
},
)
} else {
null
}
}
// Load user's bookmark list
rememberSubscription(configuredRelays, account, relayManager = relayManager) {
if (configuredRelays.isNotEmpty() && account != null) {
SubscriptionConfig(
subId = "bookmarks-${account.pubKeyHex.take(8)}",
filters =
listOf(
FilterBuilders.byAuthors(
authors = listOf(account.pubKeyHex),
kinds = listOf(BookmarkListEvent.KIND),
limit = 1,
),
),
relays = configuredRelays,
onEvent = { event, _, _, _ ->
if (event is BookmarkListEvent) {
bookmarkList = event
// Extract public bookmarked event IDs
val pubIds =
event
.publicBookmarks()
.filterIsInstance<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
FeedMode.FOLLOWING -> {
DesktopFollowingFeedFilter(localCache) {
localCache.followedUsers.value
}
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Clear events and reset EOSE when feed mode changes
remember(feedMode) {
eventState.clear()
eoseReceivedCount = 0
}
// Subscribe to feed based on mode
rememberSubscription(configuredRelays, feedMode, followedUsers, relayManager = relayManager) {
if (configuredRelays.isEmpty()) {
return@rememberSubscription null
}
when (feedMode) {
FeedMode.GLOBAL -> {
createGlobalFeedSubscription(
relays = configuredRelays,
onEvent = { event, _, relay, _ ->
subscriptionsCoordinator?.consumeEvent(event, relay)
// Store metadata events in cache
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
}
eventState.addItem(event)
},
onEose = { _, _ ->
eoseReceivedCount++
},
)
}
FeedMode.FOLLOWING -> {
if (followedUsers.isNotEmpty()) {
createFollowingFeedSubscription(
relays = configuredRelays,
followedUsers = followedUsers.toList(),
onEvent = { event, _, _, _ ->
// Store metadata events in cache
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
}
eventState.addItem(event)
},
onEose = { _, _ ->
eoseReceivedCount++
},
)
} else {
null
}
DesktopFeedViewModel(filter, localCache)
}
// Cancel old ViewModel's viewModelScope on recreation
DisposableEffect(viewModel) {
onDispose { viewModel.destroy() }
}
val feedState by viewModel.feedState.feedContent.collectAsState()
// Load metadata for visible notes via Coordinator (rate-limited)
LaunchedEffect(feedState, subscriptionsCoordinator) {
if (subscriptionsCoordinator != null && feedState is FeedState.Loaded) {
val notes = viewModel.feedState.visibleNotes()
if (notes.isNotEmpty()) {
subscriptionsCoordinator.loadMetadataForNotes(notes)
}
}
}
// Subscribe to zaps for visible events
val eventIds = events.map { it.id }
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
// Request interaction subscriptions (zaps, reactions, reposts) for visible notes
DisposableEffect(feedState, subscriptionsCoordinator) {
val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {}
val notes = viewModel.feedState.visibleNotes()
val noteIds = notes.mapNotNull { it.event?.id }
if (noteIds.isEmpty()) return@DisposableEffect onDispose {}
createZapsSubscription(
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
if (event is LnZapEvent) {
val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription
val targetEventId = event.zappedPost().firstOrNull() ?: return@createZapsSubscription
zapsByEvent =
zapsByEvent.toMutableMap().apply {
val existing = this[targetEventId] ?: emptyList()
if (existing.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) {
this[targetEventId] = existing + receipt
}
}
}
},
)
}
// Subscribe to metadata for zap senders (to show display names)
val zapSenderPubkeys =
zapsByEvent.values
.flatten()
.map { it.senderPubKey }
.distinct()
rememberSubscription(configuredRelays, zapSenderPubkeys, relayManager = relayManager) {
if (configuredRelays.isEmpty() || zapSenderPubkeys.isEmpty()) {
return@rememberSubscription null
}
// Only fetch metadata for users we don't have yet
val missingPubkeys =
zapSenderPubkeys.filter { pubkey ->
localCache
.getUserIfExists(pubkey)
?.metadataOrNull()
?.flow
?.value == null
}
if (missingPubkeys.isEmpty()) {
return@rememberSubscription null
}
createBatchMetadataSubscription(
relays = configuredRelays,
pubKeyHexList = missingPubkeys,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
}
},
)
}
// Subscribe to reactions for visible events
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
createReactionsSubscription(
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
if (event is ReactionEvent) {
val targetEventId = event.originalPost().firstOrNull() ?: return@createReactionsSubscription
reactionIdsByEvent =
reactionIdsByEvent.toMutableMap().apply {
val existing = this[targetEventId] ?: emptySet()
this[targetEventId] = existing + event.id
}
}
},
)
}
// Subscribe to replies for visible events
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
createRepliesSubscription(
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
// Find the event this is replying to
val replyToId =
event.tags
.filter { it.size >= 2 && it[0] == "e" }
.lastOrNull()
?.get(1) ?: return@createRepliesSubscription
if (replyToId in eventIds) {
replyIdsByEvent =
replyIdsByEvent.toMutableMap().apply {
val existing = this[replyToId] ?: emptySet()
this[replyToId] = existing + event.id
}
}
},
)
}
// Subscribe to reposts for visible events
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
createRepostsSubscription(
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
if (event is RepostEvent) {
val targetEventId = event.boostedEventId() ?: return@createRepostsSubscription
repostIdsByEvent =
repostIdsByEvent.toMutableMap().apply {
val existing = this[targetEventId] ?: emptySet()
this[targetEventId] = existing + event.id
}
}
},
)
}
// Subscribe to metadata for note authors + mentioned users
val authorPubkeys = events.map { it.pubKey }.distinct()
val mentionedPubkeys =
remember(events) {
val parser = UrlParser()
events
.flatMap { event ->
val urls = parser.parseValidUrls(event.content)
extractMentionedPubkeys(urls.bech32s)
}.distinct()
}
val allPubkeys = remember(authorPubkeys, mentionedPubkeys) { (authorPubkeys + mentionedPubkeys).distinct() }
// Use coordinator for rate-limited metadata loading (preferred)
LaunchedEffect(allPubkeys, subscriptionsCoordinator) {
if (subscriptionsCoordinator != null && allPubkeys.isNotEmpty()) {
subscriptionsCoordinator.loadMetadataForPubkeys(allPubkeys)
}
}
// Fallback subscription if coordinator not available
rememberSubscription(configuredRelays, allPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
// Skip if using coordinator
if (subscriptionsCoordinator != null) {
return@rememberSubscription null
}
if (configuredRelays.isEmpty() || allPubkeys.isEmpty()) {
return@rememberSubscription null
}
// Only fetch metadata for users we don't have yet
val missingPubkeys =
allPubkeys.filter { pubkey ->
localCache
.getUserIfExists(pubkey)
?.metadataOrNull()
?.flow
?.value == null
}
if (missingPubkeys.isEmpty()) {
return@rememberSubscription null
}
createBatchMetadataSubscription(
relays = configuredRelays,
pubKeyHexList = missingPubkeys,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
}
},
)
val relays =
relayManager.relayStatuses.value.keys
val subId = coordinator.requestInteractions(noteIds, relays)
onDispose { coordinator.releaseInteractions(subId) }
}
@OptIn(ExperimentalLayoutApi::class)
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
// Header with compose button — wraps on narrow columns
FlowRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Column {
FlowRow(
verticalArrangement = Arrangement.Center,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
// Feed mode selector
if (account != null) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip(
selected = feedMode == FeedMode.GLOBAL,
onClick = {
feedMode = FeedMode.GLOBAL
DesktopPreferences.feedMode = FeedMode.GLOBAL
},
label = { Text("Global") },
)
FilterChip(
selected = feedMode == FeedMode.FOLLOWING,
onClick = {
feedMode = FeedMode.FOLLOWING
DesktopPreferences.feedMode = FeedMode.FOLLOWING
},
label = { Text("Following") },
)
}
}
}
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${connectedRelays.size} relays connected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (feedMode == FeedMode.FOLLOWING) {
Text(
"${followedUsers.size} followed",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(8.dp))
IconButton(
onClick = { relayManager.connect() },
modifier = Modifier.size(24.dp),
) {
Icon(
Icons.Default.Refresh,
contentDescription = "Refresh",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
}
}
// New Post button (primary action)
Button(
onClick = onCompose,
enabled = account != null && !account.isReadOnly,
) {
Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("New Post")
}
}
// Header with compose button
FeedHeader(
feedMode = feedMode,
account = account,
connectedRelays = connectedRelays,
followedUsersCount = followedUsers.size,
onFeedModeChange = { mode ->
feedMode = mode
DesktopPreferences.feedMode = mode
},
onRefresh = { relayManager.connect() },
onCompose = onCompose,
)
Spacer(Modifier.height(8.dp))
if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...")
} else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) {
LoadingState("Loading followed users...")
} else if (events.isEmpty() && !initialLoadComplete) {
LoadingState("Loading notes...")
} else if (events.isEmpty() && initialLoadComplete) {
EmptyState(
title =
if (feedMode == FeedMode.FOLLOWING) {
"No notes from followed users"
} else {
"No notes found"
},
description =
if (feedMode == FeedMode.FOLLOWING) {
"Notes from people you follow will appear here"
} else {
"Notes from the network will appear here"
},
onRefresh = { relayManager.connect() },
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Use distinctBy to prevent duplicate key crashes from events with same ID
items(events.distinctBy { it.id }, key = { it.id }) { event ->
FeedNoteCard(
event = event,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReply = { replyToEvent = event },
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
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()
// Feed content based on FeedState
when (val state = feedState) {
is FeedState.Loading -> {
if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...")
} else {
LoadingState("Loading notes...")
}
}
is FeedState.Empty -> {
EmptyState(
title =
if (feedMode == FeedMode.FOLLOWING) {
"No notes from followed users"
} else {
"No notes found"
},
zapReceipts = zapsByEvent[event.id] ?: emptyList(),
reactionCount = reactionsByEvent[event.id] ?: 0,
replyCount = repliesByEvent[event.id] ?: 0,
repostCount = repostsByEvent[event.id] ?: 0,
bookmarkList = bookmarkList,
isBookmarked = bookmarkedEventIds.contains(event.id),
onBookmarkChanged = { newList ->
bookmarkList = newList
val pubIds =
newList
.publicBookmarks()
.filterIsInstance<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
description =
if (feedMode == FeedMode.FOLLOWING) {
"Notes from people you follow will appear here"
} else {
"Notes from the network will appear here"
},
)
onRefresh = { relayManager.connect() },
)
}
is FeedState.FeedError -> {
EmptyState(
title = "Error loading feed",
description = state.errorMessage,
onRefresh = { relayManager.connect() },
)
}
is FeedState.Loaded -> {
val loadedState by state.feed.collectAsState()
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(loadedState.list, key = { it.idHex }) { note ->
FeedNoteCard(
note = note,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReply = { replyToEvent = note.event },
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
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()
},
)
}
}
}
}
} // end Column
}
// Reply dialog
if (replyToEvent != null && account != null) {
@@ -675,5 +324,91 @@ fun FeedScreen(
onDismiss = { lightboxState = null },
)
}
} // end Box
}
}
/**
* Feed header with title, mode selector, relay count, and compose button.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun FeedHeader(
feedMode: FeedMode,
account: AccountState.LoggedIn?,
connectedRelays: Set<Any>,
followedUsersCount: Int,
onFeedModeChange: (FeedMode) -> Unit,
onRefresh: () -> Unit,
onCompose: () -> Unit,
) {
FlowRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Column {
FlowRow(
verticalArrangement = Arrangement.Center,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
if (account != null) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip(
selected = feedMode == FeedMode.GLOBAL,
onClick = { onFeedModeChange(FeedMode.GLOBAL) },
label = { Text("Global") },
)
FilterChip(
selected = feedMode == FeedMode.FOLLOWING,
onClick = { onFeedModeChange(FeedMode.FOLLOWING) },
label = { Text("Following") },
)
}
}
}
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${connectedRelays.size} relays connected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (feedMode == FeedMode.FOLLOWING) {
Text(
" \u2022 $followedUsersCount followed",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(8.dp))
IconButton(
onClick = onRefresh,
modifier = Modifier.size(24.dp),
) {
Icon(
Icons.Default.Refresh,
contentDescription = "Refresh",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
}
}
Button(
onClick = onCompose,
enabled = account != null && !account.isReadOnly,
) {
Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("New Post")
}
}
}
@@ -786,25 +786,29 @@ fun UserProfileScreen(
else -> {
items(events.distinctBy { it.id }, key = { it.id }) { event ->
FeedNoteCard(
event = event,
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()
},
)
// 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()
},
)
}
}
}
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedFilter
import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
@@ -45,4 +46,12 @@ class DesktopFeedViewModel(
feedState.refreshSuspended()
}
}
/**
* Cancel viewModelScope. ViewModel.clear() is internal in lifecycle KMP,
* so Desktop composables use this for cleanup via DisposableEffect.
*/
fun destroy() {
viewModelScope.cancel()
}
}