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:
+231
-496
@@ -45,49 +45,32 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
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
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
|
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
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.DesktopPreferences
|
||||||
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.DesktopFollowingFeedFilter
|
||||||
|
import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter
|
||||||
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.FeedMode
|
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.media.LightboxOverlay
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
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.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(
|
data class LightboxState(
|
||||||
val urls: List<String>,
|
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
|
@Composable
|
||||||
fun FeedNoteCard(
|
fun FeedNoteCard(
|
||||||
event: Event,
|
note: Note,
|
||||||
relayManager: DesktopRelayConnectionManager,
|
relayManager: DesktopRelayConnectionManager,
|
||||||
localCache: DesktopLocalCache,
|
localCache: DesktopLocalCache,
|
||||||
account: AccountState.LoggedIn?,
|
account: AccountState.LoggedIn?,
|
||||||
@@ -112,15 +96,25 @@ fun FeedNoteCard(
|
|||||||
onNavigateToThread: (String) -> Unit = {},
|
onNavigateToThread: (String) -> Unit = {},
|
||||||
onImageClick: ((List<String>, Int) -> Unit)? = null,
|
onImageClick: ((List<String>, Int) -> Unit)? = null,
|
||||||
onMediaClick: ((List<String>, Int, Float) -> 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 {
|
Column {
|
||||||
NoteCard(
|
NoteCard(
|
||||||
@@ -144,15 +138,12 @@ fun FeedNoteCard(
|
|||||||
onReplyClick = onReply,
|
onReplyClick = onReply,
|
||||||
onZapFeedback = onZapFeedback,
|
onZapFeedback = onZapFeedback,
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
zapCount = zapReceipts.size,
|
zapCount = note.zaps.size,
|
||||||
zapAmountSats = zapAmountSats,
|
zapAmountSats = zapAmount.toLong(),
|
||||||
zapReceipts = zapReceipts,
|
zapReceipts = emptyList(), // TODO: extract ZapReceipts from Note.zaps
|
||||||
reactionCount = reactionCount,
|
reactionCount = reactionCount,
|
||||||
replyCount = replyCount,
|
replyCount = replyCount,
|
||||||
repostCount = repostCount,
|
repostCount = repostCount,
|
||||||
bookmarkList = bookmarkList,
|
|
||||||
isBookmarked = isBookmarked,
|
|
||||||
onBookmarkChanged = onBookmarkChanged,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,488 +163,146 @@ fun FeedScreen(
|
|||||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
// Configured relay URLs only — stabilized with distinctUntilChanged() to prevent
|
val followedUsers by localCache.followedUsers.collectAsState()
|
||||||
// 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()
|
|
||||||
var replyToEvent by remember { mutableStateOf<Event?>(null) }
|
var replyToEvent by remember { mutableStateOf<Event?>(null) }
|
||||||
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
||||||
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
|
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
|
// DesktopFeedViewModel keyed on feedMode — recreated on mode switch
|
||||||
var eoseReceivedCount by remember { mutableStateOf(0) }
|
val viewModel =
|
||||||
val initialLoadComplete = eoseReceivedCount > 0
|
remember(feedMode) {
|
||||||
|
val filter =
|
||||||
// Load followed users for Following feed mode
|
when (feedMode) {
|
||||||
rememberSubscription(configuredRelays, account, feedMode, relayManager = relayManager) {
|
FeedMode.GLOBAL -> {
|
||||||
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
|
DesktopGlobalFeedFilter(localCache)
|
||||||
createContactListSubscription(
|
|
||||||
relays = configuredRelays,
|
|
||||||
pubKeyHex = account.pubKeyHex,
|
|
||||||
onEvent = { event, _, relay, _ ->
|
|
||||||
if (event is ContactListEvent) {
|
|
||||||
val follows = event.verifiedFollowKeySet()
|
|
||||||
followedUsers = follows
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load user's bookmark list
|
FeedMode.FOLLOWING -> {
|
||||||
rememberSubscription(configuredRelays, account, relayManager = relayManager) {
|
DesktopFollowingFeedFilter(localCache) {
|
||||||
if (configuredRelays.isNotEmpty() && account != null) {
|
localCache.followedUsers.value
|
||||||
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
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
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
|
// Request interaction subscriptions (zaps, reactions, reposts) for visible notes
|
||||||
val eventIds = events.map { it.id }
|
DisposableEffect(feedState, subscriptionsCoordinator) {
|
||||||
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
|
val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {}
|
||||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
|
val notes = viewModel.feedState.visibleNotes()
|
||||||
return@rememberSubscription null
|
val noteIds = notes.mapNotNull { it.event?.id }
|
||||||
}
|
if (noteIds.isEmpty()) return@DisposableEffect onDispose {}
|
||||||
|
|
||||||
createZapsSubscription(
|
val relays =
|
||||||
relays = configuredRelays,
|
relayManager.relayStatuses.value.keys
|
||||||
eventIds = eventIds,
|
val subId = coordinator.requestInteractions(noteIds, relays)
|
||||||
onEvent = { event, _, _, _ ->
|
onDispose { coordinator.releaseInteractions(subId) }
|
||||||
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)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Header with compose button — wraps on narrow columns
|
// Header with compose button
|
||||||
FlowRow(
|
FeedHeader(
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
feedMode = feedMode,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
account = account,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
connectedRelays = connectedRelays,
|
||||||
) {
|
followedUsersCount = followedUsers.size,
|
||||||
Column {
|
onFeedModeChange = { mode ->
|
||||||
FlowRow(
|
feedMode = mode
|
||||||
verticalArrangement = Arrangement.Center,
|
DesktopPreferences.feedMode = mode
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
},
|
||||||
) {
|
onRefresh = { relayManager.connect() },
|
||||||
Text(
|
onCompose = onCompose,
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
if (connectedRelays.isEmpty()) {
|
// Feed content based on FeedState
|
||||||
LoadingState("Connecting to relays...")
|
when (val state = feedState) {
|
||||||
} else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) {
|
is FeedState.Loading -> {
|
||||||
LoadingState("Loading followed users...")
|
if (connectedRelays.isEmpty()) {
|
||||||
} else if (events.isEmpty() && !initialLoadComplete) {
|
LoadingState("Connecting to relays...")
|
||||||
LoadingState("Loading notes...")
|
} else {
|
||||||
} else if (events.isEmpty() && initialLoadComplete) {
|
LoadingState("Loading notes...")
|
||||||
EmptyState(
|
}
|
||||||
title =
|
}
|
||||||
if (feedMode == FeedMode.FOLLOWING) {
|
|
||||||
"No notes from followed users"
|
is FeedState.Empty -> {
|
||||||
} else {
|
EmptyState(
|
||||||
"No notes found"
|
title =
|
||||||
},
|
if (feedMode == FeedMode.FOLLOWING) {
|
||||||
description =
|
"No notes from followed users"
|
||||||
if (feedMode == FeedMode.FOLLOWING) {
|
} else {
|
||||||
"Notes from people you follow will appear here"
|
"No notes found"
|
||||||
} 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()
|
|
||||||
},
|
},
|
||||||
zapReceipts = zapsByEvent[event.id] ?: emptyList(),
|
description =
|
||||||
reactionCount = reactionsByEvent[event.id] ?: 0,
|
if (feedMode == FeedMode.FOLLOWING) {
|
||||||
replyCount = repliesByEvent[event.id] ?: 0,
|
"Notes from people you follow will appear here"
|
||||||
repostCount = repostsByEvent[event.id] ?: 0,
|
} else {
|
||||||
bookmarkList = bookmarkList,
|
"Notes from the network will appear here"
|
||||||
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
|
|
||||||
},
|
},
|
||||||
)
|
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
|
// Reply dialog
|
||||||
if (replyToEvent != null && account != null) {
|
if (replyToEvent != null && account != null) {
|
||||||
@@ -675,5 +324,91 @@ fun FeedScreen(
|
|||||||
onDismiss = { lightboxState = null },
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-19
@@ -786,25 +786,29 @@ fun UserProfileScreen(
|
|||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
||||||
FeedNoteCard(
|
// Look up Note from cache for the new FeedNoteCard API
|
||||||
event = event,
|
val note = localCache.getNoteIfExists(event.id)
|
||||||
relayManager = relayManager,
|
if (note != null) {
|
||||||
localCache = localCache,
|
FeedNoteCard(
|
||||||
account = account,
|
note = note,
|
||||||
nwcConnection = nwcConnection,
|
relayManager = relayManager,
|
||||||
onReply = onCompose,
|
localCache = localCache,
|
||||||
onZapFeedback = onZapFeedback,
|
account = account,
|
||||||
onNavigateToProfile = onNavigateToProfile,
|
nwcConnection = nwcConnection,
|
||||||
onImageClick = { urls, index ->
|
onReply = onCompose,
|
||||||
lightboxState = LightboxState(urls, index)
|
onZapFeedback = onZapFeedback,
|
||||||
},
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
onMediaClick = { urls, index, seekPos ->
|
onImageClick = { urls, index ->
|
||||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
lightboxState = LightboxState(urls, index)
|
||||||
.playVideo(urls[index], seekPos)
|
},
|
||||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
onMediaClick = { urls, index, seekPos ->
|
||||||
.toggleFullscreen()
|
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||||
},
|
.playVideo(urls[index], seekPos)
|
||||||
)
|
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||||
|
.toggleFullscreen()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -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.ui.feeds.FeedFilter
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,4 +46,12 @@ class DesktopFeedViewModel(
|
|||||||
feedState.refreshSuspended()
|
feedState.refreshSuspended()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel viewModelScope. ViewModel.clear() is internal in lifecycle KMP,
|
||||||
|
* so Desktop composables use this for cleanup via DisposableEffect.
|
||||||
|
*/
|
||||||
|
fun destroy() {
|
||||||
|
viewModelScope.cancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user