fix(mailbox): show spinner during initial folder fetch instead of empty state (#167)

selectFolder() kicked off its background syncFolder() fetch fire-and-forget
with no loading flag, so opening a per-account folder with no cached
messages yet flashed "No messages to display" for the whole IMAP fetch
instead of a spinner. Adds isSyncingFolder, a StateFlow set for the
duration of that sync (mirroring isRefreshing) and cleared via try/finally
regardless of outcome. A private latestFolderSelection token guards the
clear so a stale sync from a folder no longer selected can't hide the
spinner for whichever folder is actually selected now.

MailboxScreen's non-paged empty branch now holds NoMessagesState back
while isSyncingFolder is true, showing a CircularProgressIndicator
instead — mirroring how the unified-inbox paged branch already gates on
loadState.refresh.

Closes #149

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit was merged in pull request #167.
This commit is contained in:
Jason Ross
2026-07-02 22:25:22 +00:00
committed by GitHub
co-authored by Claude Opus 4.8 github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
parent 26d4295c30
commit a48d68c139
3 changed files with 127 additions and 1 deletions
@@ -120,6 +120,7 @@ fun MailboxScreen(
val searchActive by viewModel.searchActive.collectAsStateWithLifecycle()
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
val isSyncingFolder by viewModel.isSyncingFolder.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val selectedIds by viewModel.selectedIds.collectAsStateWithLifecycle()
val pendingConfirm by viewModel.pendingConfirm.collectAsStateWithLifecycle()
@@ -330,8 +331,16 @@ fun MailboxScreen(
}
} else if (messages.isEmpty()) {
item {
// Hold the empty state back while the folder's initial background
// sync is still in flight, so opening an uncached folder doesn't
// flash "No messages to display" before the fetch has a chance to
// populate anything (issue #149).
if (searchActive && searchQuery.isNotBlank()) {
NoResultsState(Modifier.fillParentMaxSize())
} else if (isSyncingFolder) {
Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
NoMessagesState(Modifier.fillParentMaxSize())
}
@@ -186,6 +186,22 @@ class MailboxViewModel @Inject constructor(
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
/**
* True while [selectFolder]'s background sync for the currently selected folder is in flight,
* so the screen can hold the empty state back until the initial fetch either populates the
* cache or confirms the folder really is empty (issue #149).
*/
private val _isSyncingFolder = MutableStateFlow(false)
val isSyncingFolder: StateFlow<Boolean> = _isSyncingFolder.asStateFlow()
/**
* (accountId, folder) of the most recent [selectFolder] call. Used only so a completing sync
* can tell whether it's stale — superseded by a newer folder selection — before clearing
* [_isSyncingFolder], so rapid folder switching can't let a stale sync hide the spinner for
* whichever folder is actually selected now.
*/
private var latestFolderSelection: Pair<String, String>? = null
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
@@ -365,7 +381,18 @@ class MailboxViewModel @Inject constructor(
_selectedAccountId.value = accountId
explicitDrawerAccountId.value = accountId
_selectedFolder.value = folderFullName
viewModelScope.launch { mailSyncer.syncFolder(accountId, folderFullName) }
val key = accountId to folderFullName
latestFolderSelection = key
_isSyncingFolder.value = true
viewModelScope.launch {
try {
mailSyncer.syncFolder(accountId, folderFullName)
} finally {
// Only clear if this is still the most recent selection — a stale sync superseded
// by another selectFolder() must not clobber the newer one's in-flight spinner.
if (latestFolderSelection == key) _isSyncingFolder.value = false
}
}
}
/** Returns to the unified inbox across all accounts. */