diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt index f4b80eecce..476a95563e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt @@ -27,11 +27,11 @@ import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn /** * Cross-screen index of the most recent NIP-34 pull-request update event @@ -41,29 +41,21 @@ import java.util.concurrent.atomic.AtomicBoolean * than listing updates separately. Like [GitStatusIndex], updates aren't tracked * in `Note.replies`, so a per-row cache scan would otherwise be required. * - * The kind-indexed [observeEvents] subscription replaces both the full-cache - * `onStart` scan and the per-bundle type filtering the old - * `LocalCache.live.newEventBundles` flow needed: the observable's `init()` seeds - * the matching set from the index and re-emits the whole list on every new 1619, - * so we just reduce it to the latest-per-parent map each time. PR updates are - * rare, so recomputing the full map per emission is cheaper than it scanning - * every event of every kind. + * The kind-indexed [observeEvents] re-emits the whole matching list on every new + * 1619 (and seeds it from the cache index via `init()`), so [latestByPullRequest] + * is just that list reduced to the latest-per-parent map. Shared [SharingStarted.Eagerly] + * — never `WhileSubscribed` — because callers read `.value` synchronously and must + * not see a stale map when no one is actively collecting. `null` means "not loaded yet". */ object GitPullRequestUpdateIndex { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val started = AtomicBoolean(false) - private val mutableLatestByPullRequest = MutableStateFlow?>(null) - val latestByPullRequest: StateFlow?> = mutableLatestByPullRequest.asStateFlow() - - fun startIfNeeded() { - if (!started.compareAndSet(false, true)) return - scope.launch { - LocalCache - .observeEvents(Filter(kinds = listOf(GitPullRequestUpdateEvent.KIND))) - .collect { events -> mutableLatestByPullRequest.value = latestByParent(events) } - } - } + val latestByPullRequest: StateFlow?> = + LocalCache + .observeEvents(Filter(kinds = listOf(GitPullRequestUpdateEvent.KIND))) + .map { latestByParent(it) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, null) private fun latestByParent(events: List): Map { val latest = HashMap() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt index ed48469c6a..a19dce396f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt @@ -29,11 +29,11 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn /** * Cross-screen index of the most recent NIP-34 status event (kinds @@ -42,15 +42,15 @@ import java.util.concurrent.atomic.AtomicBoolean * `Note.replies` (see `LocalCache.computeReplyTo`), so the only way to * find them otherwise would be a full cache scan per row. * - * The kind-indexed [observeEvents] subscription replaces both the full-cache - * `onStart` scan and the per-bundle type filtering the old - * `LocalCache.live.newEventBundles` flow needed: the observable's `init()` seeds - * the matching set from the index and re-emits the whole list on every new - * status event, so we just reduce it to the latest-per-target map each time. + * The kind-indexed [observeEvents] re-emits the whole matching list on every new + * status event (and seeds it from the cache index via `init()`), so [latestByTarget] + * is just that list reduced to the latest-per-target map. Shared [SharingStarted.Eagerly] + * — never `WhileSubscribed` — because callers (e.g. [isClosedOrResolved] and the feed + * filters) read `.value` synchronously and must not see a stale map when no one is + * actively collecting. `null` means "not loaded yet". */ object GitStatusIndex { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val started = AtomicBoolean(false) private val statusKinds = listOf( @@ -60,19 +60,14 @@ object GitStatusIndex { GitStatusEvent.KIND_DRAFT, ) - private val mutableLatestByTarget = MutableStateFlow?>(null) - val latestByTarget: StateFlow?> = mutableLatestByTarget.asStateFlow() + val latestByTarget: StateFlow?> = + LocalCache + .observeEvents(Filter(kinds = statusKinds)) + .map { reduceLatestByTarget(it) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, null) - fun startIfNeeded() { - if (!started.compareAndSet(false, true)) return - scope.launch { - LocalCache - .observeEvents(Filter(kinds = statusKinds)) - .collect { events -> mutableLatestByTarget.value = latestByTarget(events) } - } - } - - private fun latestByTarget(events: List): Map { + private fun reduceLatestByTarget(events: List): Map { val latest = HashMap() for (event in events) { val target = event.rootEventId() ?: continue diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index e660a4131c..99fb0abf00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -582,7 +582,6 @@ private fun RenderGitPullRequestEvent( ) { // A later pull-request update (kind 1619) revises this PR with a newer commit / // merge base. Fold the most recent one in so the card reflects the current state. - LaunchedEffect(Unit) { GitPullRequestUpdateIndex.startIfNeeded() } val updateIndex by GitPullRequestUpdateIndex.latestByPullRequest.collectAsStateWithLifecycle() val update = updateIndex?.get(note.idHex) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt index 83b8b2e9d2..ea8cc7075a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt @@ -30,7 +30,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -75,7 +74,6 @@ fun GitStatusActions( val canModerate = remember(event, note) { canModerate(repoAddress, note, accountViewModel) } if (!canModerate) return - LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() } val index by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle() if (index == null) return val current = index?.get(note.idHex) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt index 2425dfd6fc..443ca59ad1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -74,7 +73,6 @@ fun GitStatusPill( modifier: Modifier = Modifier, defaultIfMissing: StatusKind? = null, ) { - LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() } val index by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle() val map = index ?: return // hide pill until the initial scan completes — avoids a default-then-real flicker val kind = map[targetIdHex]?.statusKind() ?: defaultIfMissing ?: return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt index cdb48b8fd4..5b5f3c91e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt @@ -37,7 +37,6 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -244,7 +243,6 @@ private fun LabelChip(label: String) { /** Small "Revised" badge shown when a pull request has a later kind-1619 update. */ @Composable private fun GitRevisedChip(prIdHex: String) { - LaunchedEffect(Unit) { GitPullRequestUpdateIndex.startIfNeeded() } val index by GitPullRequestUpdateIndex.latestByPullRequest.collectAsStateWithLifecycle() if (index?.get(prIdHex) == null) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index 9994b9b795..6298397653 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -257,9 +257,8 @@ private fun GitRepositoryHome( } // Nav-card badges count only the OPEN issues/PRs. The open/closed split needs the status - // index (kinds 1630-1633), which is started here so the home reflects it without visiting - // the Issues screen first; the count is then derived directly from the live index. - LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() } + // index (kinds 1630-1633); it's an eagerly-shared StateFlow, so the home reflects it without + // visiting the Issues screen first. The count is derived directly from the live index. val statusMap by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle() val openIssueCount = remember(openIssueItems, closedIssueItems, statusMap) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt index 7794864652..a3df977d5f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt @@ -39,7 +39,6 @@ class RepositoryIssuesFeedViewModel( // Status events (kinds 1630-1633) don't mutate the issue note, so the additive // feed update can't move an item between the Open/Closed buckets on its own. // Watch the status index and force a full re-partition whenever it changes. - GitStatusIndex.startIfNeeded() viewModelScope.launch(Dispatchers.IO) { GitStatusIndex.latestByTarget.collect { invalidateData() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt index 2231f60009..e4cd1ec15d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt @@ -39,7 +39,6 @@ class RepositoryPatchesFeedViewModel( // Status events (kinds 1630-1633) don't mutate the patch/PR note, so the additive // feed update can't move an item between the Open/Closed buckets on its own. // Watch the status index and force a full re-partition whenever it changes. - GitStatusIndex.startIfNeeded() viewModelScope.launch(Dispatchers.IO) { GitStatusIndex.latestByTarget.collect { invalidateData() } }