diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 0dfaa05c0a..23616a9263 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -153,7 +153,6 @@ sealed class DesktopScreen { ) : DesktopScreen() data object Drafts : DesktopScreen() - data object Settings : DesktopScreen() } @@ -457,7 +456,7 @@ fun App( RelayUrlNormalizer.normalizeOrNull(it) }.toSet(), localCache = localCache, - ) + ).also { it.startCleanupLoop() } } // Clear cache and subscriptions on logout diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index ca08c471c1..319ec94db9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -515,6 +515,14 @@ class DesktopLocalCache : ICacheProvider { followingCounts[pubkey] = count } + // ----- Memory Cleanup ----- + + fun cleanMemory() { + notes.cleanUp() + addressableNotes.cleanUp() + users.cleanUp() + } + // ----- Stats ----- fun userCount(): Int = users.size() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 29121e4b14..f6dd01b7cb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -37,11 +37,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import java.lang.management.ManagementFactory import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds /** * Desktop-specific relay subscriptions coordinator. @@ -356,5 +361,57 @@ class DesktopRelaySubscriptionsCoordinator( unsubscribeFromDms() feedMetadata.clear() rateLimiter.reset() + cleanupJob?.cancel() + } + + // ----- Memory Cleanup ----- + + private val memoryBean = ManagementFactory.getMemoryMXBean() + private var lastCleanupTime = 0L + private var cleanupJob: Job? = null + + /** + * Starts a periodic memory cleanup coroutine. + * Checks heap usage every 30s, runs cleanup at >75% heap or every 5 minutes. + */ + fun startCleanupLoop() { + cleanupJob = + scope.launch(Dispatchers.Default) { + delay(2.minutes) + while (isActive) { + delay(30.seconds) + val heapPct = heapUsagePercent() + val elapsed = System.currentTimeMillis() - lastCleanupTime + if (heapPct > 0.75 || elapsed > 5.minutes.inWholeMilliseconds) { + runCleanup() + } + } + } + } + + private suspend fun runCleanup() { + val ops = + listOf Unit>>( + "cleanMemory" to { localCache.cleanMemory() }, + "cleanObservers" to { cleanObservers() }, + ) + ops.forEach { (name, op) -> + try { + op() + } catch (e: Exception) { + println("Cleanup $name failed: ${e.message}") + } + } + lastCleanupTime = System.currentTimeMillis() + } + + private fun cleanObservers() { + localCache.notes.forEach { _, note -> note.clearFlow() } + localCache.addressableNotes.forEach { _, note -> note.clearFlow() } + } + + private fun heapUsagePercent(): Double { + val heap = memoryBean.heapMemoryUsage + return heap.used.toDouble() / heap.max.toDouble() } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 1da11dbd6b..9a71b8bb83 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -45,7 +45,6 @@ import com.vitorpamplona.amethyst.desktop.chess.ChessScreen import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore -import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen @@ -97,8 +96,6 @@ fun DeckColumnContainer( iAccount: DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, - highlightStore: DesktopHighlightStore, - draftStore: DesktopDraftStore, appScope: CoroutineScope, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, @@ -139,8 +136,6 @@ fun DeckColumnContainer( iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, - highlightStore = highlightStore, - draftStore = draftStore, appScope = appScope, compactMode = true, onShowComposeDialog = onShowComposeDialog, @@ -149,7 +144,6 @@ fun DeckColumnContainer( onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, - onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) }, ) if (currentOverlay != null) { Surface( @@ -163,14 +157,11 @@ fun DeckColumnContainer( account = account, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, - highlightStore = highlightStore, - draftStore = draftStore, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, - onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, onBack = { navState.pop() }, ) } @@ -189,8 +180,6 @@ internal fun RootContent( iAccount: DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, - highlightStore: DesktopHighlightStore? = null, - draftStore: DesktopDraftStore? = null, appScope: CoroutineScope, compactMode: Boolean = false, onShowComposeDialog: () -> Unit, @@ -199,7 +188,6 @@ internal fun RootContent( onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, onNavigateToArticle: (String) -> Unit = {}, - onNavigateToEditor: (String?) -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -249,11 +237,8 @@ internal fun RootContent( relayManager = relayManager, localCache = localCache, account = account, - nwcConnection = nwcConnection, onNavigateToProfile = onNavigateToProfile, onNavigateToArticle = onNavigateToArticle, - onNavigateToThread = onNavigateToThread, - onZapFeedback = onZapFeedback, ) } @@ -296,7 +281,6 @@ internal fun RootContent( onBack = {}, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, - onNavigateToArticle = onNavigateToArticle, onZapFeedback = onZapFeedback, ) } @@ -352,16 +336,16 @@ internal fun RootContent( localCache = localCache, account = account, subscriptionsCoordinator = subscriptionsCoordinator, - highlightStore = highlightStore, onBack = {}, onNavigateToProfile = onNavigateToProfile, ) } - is DeckColumnType.Editor -> { + DeckColumnType.Editor -> { + val draftStore = remember { DesktopDraftStore(scope) } ArticleEditorScreen( - draftSlug = columnType.draftSlug, - draftStore = draftStore ?: remember { DesktopDraftStore(scope) }, + draftSlug = null, + draftStore = draftStore, account = account, relayManager = relayManager, onBack = {}, @@ -370,16 +354,10 @@ internal fun RootContent( } DeckColumnType.Drafts -> { + val draftStore = remember { DesktopDraftStore(scope) } DraftsScreen( - draftStore = draftStore ?: remember { DesktopDraftStore(scope) }, - onOpenEditor = { slug -> onNavigateToEditor(slug) }, - ) - } - - DeckColumnType.MyHighlights -> { - com.vitorpamplona.amethyst.desktop.ui.highlights.MyHighlightsScreen( - highlightStore = highlightStore ?: remember { DesktopHighlightStore(scope) }, - onNavigateToArticle = onNavigateToArticle, + draftStore = draftStore, + onOpenEditor = {}, ) } @@ -404,14 +382,11 @@ internal fun OverlayContent( account: AccountState.LoggedIn, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, - highlightStore: DesktopHighlightStore? = null, - draftStore: DesktopDraftStore? = null, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onZapFeedback: (ZapFeedback) -> Unit, onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, - onNavigateToArticle: (String) -> Unit = {}, onBack: () -> Unit, ) { when (screen) { @@ -426,7 +401,6 @@ internal fun OverlayContent( onBack = onBack, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, - onNavigateToArticle = onNavigateToArticle, onZapFeedback = onZapFeedback, ) } @@ -454,24 +428,11 @@ internal fun OverlayContent( localCache = localCache, account = account, subscriptionsCoordinator = subscriptionsCoordinator, - highlightStore = highlightStore, onBack = onBack, onNavigateToProfile = onNavigateToProfile, ) } - is DesktopScreen.Editor -> { - val overlayScope = androidx.compose.runtime.rememberCoroutineScope() - ArticleEditorScreen( - draftSlug = screen.draftSlug, - draftStore = draftStore ?: remember { DesktopDraftStore(overlayScope) }, - account = account, - relayManager = relayManager, - onBack = onBack, - onPublished = onBack, - ) - } - else -> { androidx.compose.material3.Text( "Unsupported screen type",