Skip to content

Commit 18046b0

Browse files
nbradburyclaude
andauthored
Improve error handling in RS post list (#22633)
* Add context menu UI to RS post list (no networking) Add three-dot overflow menu to each post card with status-aware actions: Published/Private posts get View, Read, Move to Draft, Duplicate, Share, Blaze, Stats, Comments, and Trash. Draft/Pending/Scheduled posts get Duplicate and Trash. Trashed posts get Move to Draft and Delete Permanently. Navigation actions (View, Read, Share, Stats, Comments, Blaze) are fully wired to launch the appropriate activities. Mutation actions (Move to Draft, Duplicate, Trash, Delete) are UI-only no-ops that will be connected to wordpress-rs networking in a follow-up PR. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Show "Not implemented yet" toast for menu actions that require networking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address code review feedback - Group confirmation dialog callbacks into ConfirmationDialogState data class, reducing PostRsListScreen from 19 to 15 parameters - Extract TrashConfirmationDialog and DeleteConfirmationDialog composables - Add TODO for findPost() linear scan optimization - Fix import ordering for uniffi.wp_api.PostStatus - Remove unused featuredImageId field from PostRsUiModel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix menu actions using tab instead of unreliable PostStatus pattern matching Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add Publish action and update draft post menu items Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Minor formatting change * Address review feedback: deduplicate search check, fix class equality Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Simplify confirmation dialogs and state management * Only show Stats menu item when site stats are enabled Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Replace redundant booleans with PendingConfirmation in dialog state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Only show Comments menu item when comments are open on the post Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add wordpress-rs networking for post context menu actions Replace "Not implemented yet" placeholders with actual API calls for Trash, Delete Permanently, Publish, Move to Draft, and Duplicate actions using wordpress-rs (no FluxC). After each successful mutation all initialized tab collections are refreshed so the list updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Revert Duplicate action to "Not implemented yet" toast The duplicate networking (fetching + creating on server) is deferred to a later branch so the approach can be revisited. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Animate post disappearance on trash, delete, publish, and move to draft Optimistically remove the post from local tab state on success so Compose's animateItem() can fade it out and smoothly slide remaining items up, instead of the list jumping instantly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Clean up review feedback: remove unused strings, consolidate events - Remove unused post_rs_duplicated and post_rs_error_duplicate strings - Consolidate ShowError into ShowToast (both showed a toast) - Make ConfirmationDialogState a data class - Remove trailing blank line in PostRsRestClient Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add warning logs for silent returns and O(1) post lookup index Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Simplify findPost to use early-return loop instead of cached index Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Suppress detekt LargeClass warning on PostRsListViewModel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Show no-network message before attempting post mutations Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Resolve selected site once at ViewModel init and finish Activity if null Instead of checking getSelectedSite() in 6 different places with inconsistent null-handling patterns, store the site at construction time and send Finish event (with toast) if no site is selected. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use site!! and extract getFluxCPost helper in PostRsListViewModel Replace defensive val site = site ?: return with site!! since the Activity finishes on init when site is null. Consolidate two postStore.getPostByRemotePostId calls into a getFluxCPost helper that shows a toast when the post isn't found. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Replace site!! with requireNotNull-backed non-null property Use a private _site backing field with a non-null site getter that calls requireNotNull with a descriptive message. This eliminates 14 uses of site!! and provides a clear error if site is accessed after the Activity should have finished. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Unwrap lines that were broken well before the 120 char limit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix line wrapping in PostRsRestClient to match 120-char limit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update wordpress-rs to latest trunk build Adapt to API change: WpSelfHostedService replaced by WpService with factory method, ApiUrlResolver parameter removed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Support WordPress.com sites in the RS post list Rename WpSelfHostedServiceProvider to WpServiceProvider and add WP.com support using WpService.wordpressCom() with bearer token auth. Update PostRsRestClient to route WP.com post mutations through the WP.com REST API proxy. Update ActivityLauncher gateway to allow WP.com sites when the RS post list experimental feature is enabled. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update RS post list experimental feature description Remove "self-hosted" qualifier since the feature now supports all sites with application passwords. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Updated feature description * Require application passwords for all sites and cache WP.com API client Simplify the RS post list gateway to require hasApplicationPassword() for all sites, matching the feature description. Cache WP.com WpApiClient instances by siteId to avoid recreating them on every mutation call. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add thread safety and sign-out cleanup for WP.com API clients Add @synchronized to PostRsRestClient.getApiClient() to prevent concurrent access to the wpComClients cache. Add clearWpComClients() and call it from AppInitializer on sign-out to prevent stale bearer token usage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Allow WP.com sites in RS post list without application passwords WP.com sites use OAuth bearer tokens and don't require application passwords. Update the gateway to allow WP.com sites through and update the feature description to reflect both supported site types. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add appNotifier to WP.com API clients in PostRsRestClient WP.com clients were missing the WpAppNotifier callback, so invalid authentication on post mutations would not trigger the notification handler. Wire it up to match the self-hosted client behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use dynamic auth provider for WP.com bearer tokens Replace staticWithAuth with WpDynamicAuthenticationProvider so the OAuth token is read from AccountStore on each request instead of being baked in at client creation time. This avoids stale tokens if the token is refreshed without a sign-out. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Extract shared createWpComAuthProvider helper Consolidate the identical WpDynamicAuthenticationProvider creation from PostRsRestClient and WpServiceProvider into a single package-level function in WpServiceProvider.kt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix 404 on WP.com post mutations by using WpComDotOrgApiUrlResolver The WP.com API client was using WpOrgSiteApiUrlResolver with a URL that included wp/v2/sites/{id}/, causing wordpress-rs to double the wp/v2 path segment when constructing request URLs. Use the library's WpComDotOrgApiUrlResolver instead, which correctly handles WP.com URL construction from a site ID. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add KDoc to getApiClient in PostRsRestClient Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use import alias to fix checkstyle dotorg violation in PostRsRestClient Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Move WP.com API client creation from PostRsRestClient to WpApiClientProvider Centralizes WP.com WpApiClient caching and creation in WpApiClientProvider (fluxc module), eliminating duplicate dependencies and sign-out cleanup from PostRsRestClient. The shared createWpComAuthProvider helper also moves to fluxc so both WpApiClientProvider and WpServiceProvider can use it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix import ordering in AppInitializer for WpApiClientProvider Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Updated logic for when new post list should be shown * Implement duplicate post in RS post list Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Shortened error toast * Remove unused post_rs_not_implemented_yet string Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Consolidate post action boilerplate into shared helper Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add KDocs * Add KDocs to removePostFromState and createCollection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Revert post_not_found string to original text The shortened version affected non-RS callers (EditPostActivity, GutenbergKitActivity, EditorLinkHandler, ActivityLauncher). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add author filter (Me/Everyone) to RS post list Adds the same author filtering capability as the legacy post list, allowing users on WP.com multi-author sites to filter posts by author. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix shimmer stuck on empty tab by clearing isLoading in list info observer When an API response returns no items and the collection was already empty, the data observer doesn't fire so isLoading was never cleared. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Show user avatar in author filter toolbar icon and dropdown Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address review feedback: fix NPE risk, style, and import order Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Wrap AuthorFilterButton in Box for idiomatic DropdownMenu anchoring Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fetch and display author names in RS post list Author names are resolved via a batched Users API call (mirroring the featured image pattern) and shown next to the date when filtering by "Everyone" on multi-author sites. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Clear media URL and author name caches on pull-to-refresh Adds clearCaches() to PostRsRestClient and calls it from refreshTab() when the user explicitly pulls to refresh, so stale author names and featured image URLs are evicted. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Extract AuthorFilterIcon composable to deduplicate icon logic The toolbar button and dropdown menu leading icon in AuthorFilterButton had identical rendering logic copy-pasted. Extract into a shared private AuthorFilterIcon composable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Simplify isSingleUserSite null check to idiomatic Kotlin Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add fallback icon for avatar load failure in AuthorFilterIcon Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Combine early returns in resolveAuthorNames to fix detekt ReturnCount Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add auth headers to Coil image loader for private site featured images Adds an OkHttp interceptor to the global Coil ImageLoader that uses AuthenticationUtils to attach auth headers (Bearer, Basic, Cookie) to image requests. This enables featured images to load in the Compose post list on private WPCom, private Atomic, and HTTP-auth sites. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Replace toasts with snackbars and add retry to error states in RS post list Snackbars provide better visibility and support action buttons (e.g. "Retry") compared to toasts. When a refresh or pagination error occurs with stale posts already displayed, the posts now remain visible with a snackbar instead of being replaced by a full-screen error. The full-screen error state also gains a retry button so users no longer need to discover pull-to-refresh. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Show friendly error messages instead of raw wordpress-rs exceptions Network errors from wordpress-rs surface verbose internal messages like "UniffiWpApiException$RequestExecutionFailed..." to the user. Replace all raw e.message / listInfo.errorMessage usage with a helper that checks network state and returns the appropriate user-facing string. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use more descriptive error subtitles and center-align error text The error screen title and subtitle were both "An error occurred". Now the subtitle gives actionable context: a network-specific message when offline, or a generic request failure message otherwise. The subtitle text is also center-aligned to match the title. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add error simulation toggles for manual testing Flip any SIMULATE_*_ERROR constant to true to trigger that error path. Remove before merging. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Updated comment * Detect authentication errors and show a specific message Inspect WpApiException subtypes to distinguish offline, auth, and generic errors. Invalid or revoked application passwords now show "Your authentication has expired. Please sign in again." instead of the generic request failure message. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add SIMULATE_AUTH_ERROR toggle for testing auth error path Throws a WpApiException with HttpAuthenticationRejectedError to verify the "Your authentication has expired" message appears. Remove before merging. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Unwrap FetchException.Api before matching error types The friendly error messages weren't matching specific error types because exceptions are now wrapped in FetchException.Api. Unwrap the inner WpApiException before checking for auth errors, offline status, and other specific error codes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Hide retry button for authentication errors Auth failures won't resolve by retrying, so suppress the retry action in both the full-screen error state and snackbar messages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove error simulation code from PostRsListViewModel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Extract unwrapException helper in PostRsListViewModel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 09ba92d commit 18046b0

6 files changed

Lines changed: 165 additions & 24 deletions

File tree

WordPress/src/main/java/org/wordpress/android/ui/postsrs/PostRsListActivity.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class PostRsListActivity : BaseAppCompatActivity() {
5353
onConfirm = viewModel::onConfirmPendingAction,
5454
onDismiss = viewModel::onDismissPendingAction
5555
),
56+
snackbarMessages = viewModel.snackbarMessages,
5657
onSearchOpen = viewModel::onSearchOpen,
5758
onSearchQueryChanged = viewModel::onSearchQueryChanged,
5859
onSearchClose = viewModel::onSearchClose,

WordPress/src/main/java/org/wordpress/android/ui/postsrs/PostRsListUiState.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ import uniffi.wp_api.PostStatus
1010
import uniffi.wp_mobile.FullEntityAnyPostWithEditContext
1111
import uniffi.wp_mobile.PostItemState
1212

13+
data class SnackbarMessage(
14+
val message: String,
15+
val actionLabel: String? = null,
16+
val onAction: (() -> Unit)? = null
17+
)
18+
1319
sealed interface PendingConfirmation {
1420
data class Trash(val postId: Long) : PendingConfirmation
1521
data class Delete(val postId: Long) : PendingConfirmation
@@ -27,7 +33,8 @@ data class PostTabUiState(
2733
val isRefreshing: Boolean = false,
2834
val isLoadingMore: Boolean = false,
2935
val canLoadMore: Boolean = false,
30-
val error: String? = null
36+
val error: String? = null,
37+
val isAuthError: Boolean = false
3138
)
3239

3340
data class PostRsUiModel(

WordPress/src/main/java/org/wordpress/android/ui/postsrs/PostRsListViewModel.kt

Lines changed: 119 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,11 @@ import rs.wordpress.cache.kotlin.getObservablePostMetadataCollectionWithEditCont
3838
import rs.wordpress.cache.kotlin.hasMorePages
3939
import uniffi.wp_api.PostEndpointType
4040
import uniffi.wp_api.PostStatus
41+
import uniffi.wp_api.RequestExecutionErrorReason
42+
import uniffi.wp_api.WpApiException
4143
import uniffi.wp_api.WpApiParamPostsOrderBy
44+
import uniffi.wp_api.WpErrorCode
45+
import uniffi.wp_mobile.FetchException
4246
import uniffi.wp_mobile.PostListFilter
4347
import uniffi.wp_mobile_cache.ListState
4448
import javax.inject.Inject
@@ -75,6 +79,9 @@ class PostRsListViewModel @Inject constructor(
7579
private val _events = Channel<PostRsListEvent>(Channel.BUFFERED)
7680
val events = _events.receiveAsFlow()
7781

82+
private val _snackbarMessages = Channel<SnackbarMessage>(Channel.BUFFERED)
83+
val snackbarMessages = _snackbarMessages.receiveAsFlow()
84+
7885
private val _pendingConfirmation = MutableStateFlow<PendingConfirmation?>(null)
7986
val pendingConfirmation: StateFlow<PendingConfirmation?> = _pendingConfirmation.asStateFlow()
8087

@@ -302,9 +309,50 @@ class PostRsListViewModel @Inject constructor(
302309
_events.trySend(PostRsListEvent.EditPost(site, newPost))
303310
}
304311

312+
private fun unwrapException(e: Exception?): Exception? =
313+
(e as? FetchException.Api)?.v1 ?: e
314+
315+
/**
316+
* Returns true when the exception represents an authentication failure
317+
* (rejected credentials, missing app-password, etc.).
318+
*/
319+
private fun isAuthError(e: Exception?): Boolean {
320+
val apiException = unwrapException(e)
321+
val reason = (apiException as? WpApiException.RequestExecutionFailed)?.reason
322+
val errorCode = (apiException as? WpApiException.WpException)?.errorCode
323+
return reason is RequestExecutionErrorReason.HttpAuthenticationRejectedError ||
324+
reason is RequestExecutionErrorReason.HttpAuthenticationRequiredError ||
325+
errorCode is WpErrorCode.Unauthorized ||
326+
errorCode is WpErrorCode.ApplicationPasswordNotFound ||
327+
errorCode is WpErrorCode.NoAuthenticatedAppPassword
328+
}
329+
330+
/**
331+
* Returns a user-friendly error subtitle based on the exception type.
332+
* Detects offline, authentication, and generic errors. Raw
333+
* exception/API messages are never surfaced to the user.
334+
*/
335+
private fun friendlyErrorMessage(e: Exception? = null): String {
336+
val apiException = unwrapException(e)
337+
val reason = (apiException as? WpApiException.RequestExecutionFailed)?.reason
338+
339+
val resId = when {
340+
reason is RequestExecutionErrorReason.DeviceIsOfflineError ||
341+
!networkUtilsWrapper.isNetworkAvailable() ->
342+
R.string.error_generic_network
343+
344+
isAuthError(e) -> R.string.post_rs_error_auth
345+
346+
else -> R.string.request_failed_message
347+
}
348+
return resourceProvider.getString(resId)
349+
}
350+
305351
private fun checkNetwork(): Boolean {
306352
if (!networkUtilsWrapper.isNetworkAvailable()) {
307-
_events.trySend(PostRsListEvent.ShowToast(R.string.no_network_message))
353+
_snackbarMessages.trySend(
354+
SnackbarMessage(resourceProvider.getString(R.string.no_network_message))
355+
)
308356
return false
309357
}
310358
return true
@@ -324,12 +372,16 @@ class PostRsListViewModel @Inject constructor(
324372
when (result) {
325373
is PostActionResult.Success -> {
326374
removePostFromState(postId)
327-
_events.trySend(PostRsListEvent.ShowToast(successResId))
375+
_snackbarMessages.trySend(
376+
SnackbarMessage(resourceProvider.getString(successResId))
377+
)
328378
refreshAllTabs()
329379
}
330380
is PostActionResult.Error -> {
331381
AppLog.e(AppLog.T.POSTS, "Post action failed: ${result.message}")
332-
_events.trySend(PostRsListEvent.ShowToast(errorResId))
382+
_snackbarMessages.trySend(
383+
SnackbarMessage(resourceProvider.getString(errorResId))
384+
)
333385
}
334386
}
335387
}
@@ -363,7 +415,9 @@ class PostRsListViewModel @Inject constructor(
363415
private fun getFluxCPost(remotePostId: Long): PostModel? {
364416
val post = postStore.getPostByRemotePostId(remotePostId, site)
365417
if (post == null) {
366-
_events.trySend(PostRsListEvent.ShowToast(R.string.post_not_found))
418+
_snackbarMessages.trySend(
419+
SnackbarMessage(resourceProvider.getString(R.string.post_not_found))
420+
)
367421
}
368422
return post
369423
}
@@ -456,7 +510,8 @@ class PostRsListViewModel @Inject constructor(
456510
initializingTabs.remove(tab)
457511
updateTabUiState(tab) {
458512
PostTabUiState(
459-
error = e.message ?: resourceProvider.getString(R.string.error_generic)
513+
error = friendlyErrorMessage(e),
514+
isAuthError = isAuthError(e)
460515
)
461516
}
462517
}
@@ -525,11 +580,29 @@ class PostRsListViewModel @Inject constructor(
525580
} catch (e: Exception) {
526581
AppLog.e(AppLog.T.POSTS, "Failed to refresh tab $tab", e)
527582
userRefreshingTabs.remove(tab)
528-
updateTabUiState(tab) {
529-
copy(
530-
isLoading = false, isRefreshing = false,
531-
error = e.message ?: resourceProvider.getString(R.string.error_generic)
583+
val message = friendlyErrorMessage(e)
584+
if (getTabUiState(tab).posts.isNotEmpty()) {
585+
updateTabUiState(tab) {
586+
copy(isLoading = false, isRefreshing = false, error = null)
587+
}
588+
val authError = isAuthError(e)
589+
_snackbarMessages.trySend(
590+
SnackbarMessage(
591+
message = message,
592+
actionLabel = if (authError) null
593+
else resourceProvider.getString(R.string.retry),
594+
onAction = if (authError) null
595+
else ({ refreshTab(tab) })
596+
)
532597
)
598+
} else {
599+
updateTabUiState(tab) {
600+
copy(
601+
isLoading = false, isRefreshing = false,
602+
error = message,
603+
isAuthError = isAuthError(e)
604+
)
605+
}
533606
}
534607
}
535608
}
@@ -551,6 +624,9 @@ class PostRsListViewModel @Inject constructor(
551624
} catch (e: Exception) {
552625
AppLog.e(AppLog.T.POSTS, "Failed to load more for tab $tab", e)
553626
updateTabUiState(tab) { copy(isLoadingMore = false) }
627+
_snackbarMessages.trySend(
628+
SnackbarMessage(friendlyErrorMessage(e))
629+
)
554630
}
555631
}
556632
}
@@ -674,18 +750,41 @@ class PostRsListViewModel @Inject constructor(
674750

675751
if (!fetchingFirstPage) userRefreshingTabs.remove(tab)
676752

677-
updateTabUiState(tab) {
678-
copy(
679-
isLoading = isLoading && fetchingFirstPage,
680-
isRefreshing = isUserRefresh && fetchingFirstPage,
681-
isLoadingMore = listInfo?.state == ListState.FETCHING_NEXT_PAGE,
682-
canLoadMore = morePages,
683-
error = if (listInfo?.state == ListState.ERROR) {
684-
listInfo.errorMessage ?: resourceProvider.getString(R.string.error_generic)
685-
} else {
686-
null
687-
}
753+
val isError = listInfo?.state == ListState.ERROR
754+
val hasPosts = getTabUiState(tab).posts.isNotEmpty()
755+
val errorMessage = if (isError) friendlyErrorMessage() else null
756+
757+
if (isError && hasPosts) {
758+
val authError = getTabUiState(tab).isAuthError
759+
updateTabUiState(tab) {
760+
copy(
761+
isLoading = false,
762+
isRefreshing = false,
763+
isLoadingMore = false,
764+
canLoadMore = morePages,
765+
error = null
766+
)
767+
}
768+
_snackbarMessages.trySend(
769+
SnackbarMessage(
770+
message = errorMessage.orEmpty(),
771+
actionLabel = if (authError) null
772+
else resourceProvider.getString(R.string.retry),
773+
onAction = if (authError) null
774+
else ({ refreshTab(tab) })
775+
)
688776
)
777+
} else {
778+
updateTabUiState(tab) {
779+
copy(
780+
isLoading = isLoading && fetchingFirstPage,
781+
isRefreshing = isUserRefresh && fetchingFirstPage,
782+
isLoadingMore = listInfo?.state
783+
== ListState.FETCHING_NEXT_PAGE,
784+
canLoadMore = morePages,
785+
error = errorMessage
786+
)
787+
}
689788
}
690789
}
691790

WordPress/src/main/java/org/wordpress/android/ui/postsrs/screens/PostRsListScreen.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ import androidx.compose.material3.IconButton
2929
import androidx.compose.material3.MaterialTheme
3030
import androidx.compose.material3.PrimaryScrollableTabRow
3131
import androidx.compose.material3.Scaffold
32+
import androidx.compose.material3.SnackbarHost
33+
import androidx.compose.material3.SnackbarHostState
34+
import androidx.compose.material3.SnackbarResult
3235
import androidx.compose.material3.Tab
3336
import androidx.compose.material3.Text
3437
import androidx.compose.material3.TextButton
@@ -55,12 +58,15 @@ import androidx.compose.ui.res.stringResource
5558
import androidx.compose.ui.text.input.ImeAction
5659
import androidx.compose.ui.unit.dp
5760
import coil.compose.AsyncImage
61+
import kotlinx.coroutines.flow.Flow
62+
import kotlinx.coroutines.flow.emptyFlow
5863
import kotlinx.coroutines.launch
5964
import org.wordpress.android.R
6065
import org.wordpress.android.ui.posts.AuthorFilterSelection
6166
import org.wordpress.android.ui.postsrs.ConfirmationDialogState
6267
import org.wordpress.android.ui.postsrs.PendingConfirmation
6368
import org.wordpress.android.ui.postsrs.PostRsListTab
69+
import org.wordpress.android.ui.postsrs.SnackbarMessage
6470
import org.wordpress.android.ui.postsrs.PostRsListViewModel.Companion.MIN_SEARCH_QUERY_LENGTH
6571
import org.wordpress.android.ui.postsrs.PostRsMenuAction
6672
import org.wordpress.android.ui.postsrs.PostTabUiState
@@ -75,6 +81,7 @@ fun PostRsListScreen(
7581
isAuthorFilterSupported: Boolean,
7682
avatarUrl: String?,
7783
confirmationDialog: ConfirmationDialogState,
84+
snackbarMessages: Flow<SnackbarMessage> = emptyFlow(),
7885
onSearchOpen: () -> Unit,
7986
onSearchQueryChanged: (String, PostRsListTab) -> Unit,
8087
onSearchClose: (PostRsListTab) -> Unit,
@@ -93,8 +100,22 @@ fun PostRsListScreen(
93100
val focusRequester = remember { FocusRequester() }
94101
val focusManager = LocalFocusManager.current
95102
val activeTab = tabs[pagerState.settledPage]
103+
val snackbarHostState = remember { SnackbarHostState() }
104+
105+
LaunchedEffect(snackbarMessages) {
106+
snackbarMessages.collect { msg ->
107+
val result = snackbarHostState.showSnackbar(
108+
message = msg.message,
109+
actionLabel = msg.actionLabel
110+
)
111+
if (result == SnackbarResult.ActionPerformed) {
112+
msg.onAction?.invoke()
113+
}
114+
}
115+
}
96116

97117
Scaffold(
118+
snackbarHost = { SnackbarHost(snackbarHostState) },
98119
topBar = {
99120
TopAppBar(
100121
title = {

WordPress/src/main/java/org/wordpress/android/ui/postsrs/screens/PostRsTabListScreen.kt

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
2828
import androidx.compose.ui.Alignment
2929
import androidx.compose.ui.Modifier
3030
import androidx.compose.ui.res.stringResource
31+
import androidx.compose.ui.text.style.TextAlign
3132
import androidx.compose.ui.unit.dp
3233
import org.wordpress.android.R
3334
import org.wordpress.android.ui.postsrs.PostRsMenuAction
@@ -68,7 +69,10 @@ fun PostRsTabListScreen(
6869
isSearchIdle -> Box(Modifier.fillMaxSize())
6970
state.isLoading -> ShimmerList()
7071
state.error != null && state.posts.isEmpty() -> {
71-
ErrorContent(state.error)
72+
ErrorContent(
73+
error = state.error,
74+
onRetry = if (state.isAuthError) null else onRefresh
75+
)
7276
}
7377
state.posts.isEmpty() && !state.isRefreshing -> {
7478
EmptyContent(
@@ -170,7 +174,7 @@ private fun ShimmerList() {
170174
}
171175

172176
@Composable
173-
private fun ErrorContent(error: String) {
177+
private fun ErrorContent(error: String, onRetry: (() -> Unit)?) {
174178
Column(
175179
modifier = Modifier
176180
.fillMaxSize()
@@ -187,8 +191,15 @@ private fun ErrorContent(error: String) {
187191
Text(
188192
text = error,
189193
style = MaterialTheme.typography.bodyMedium,
190-
color = MaterialTheme.colorScheme.onSurfaceVariant
194+
color = MaterialTheme.colorScheme.onSurfaceVariant,
195+
textAlign = TextAlign.Center
191196
)
197+
if (onRetry != null) {
198+
Spacer(modifier = Modifier.height(16.dp))
199+
Button(onClick = onRetry) {
200+
Text(text = stringResource(R.string.retry))
201+
}
202+
}
192203
}
193204
}
194205

WordPress/src/main/res/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,6 +1010,8 @@
10101010
<string name="post_rs_error_trash">Failed to trash post</string>
10111011
<string name="post_rs_error_delete">Failed to delete post</string>
10121012
<string name="post_rs_error_update_status">Failed to update post status</string>
1013+
<string name="post_rs_error_load_more">Couldn\'t load more posts</string>
1014+
<string name="post_rs_error_auth">Your authentication has expired. Please sign in again.</string>
10131015
<string name="post_types_screen_title">Post Types</string>
10141016

10151017
<!-- Debug settings -->

0 commit comments

Comments
 (0)