diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml index 72b6c62ba88c..6131afb24c14 100644 --- a/WordPress/src/main/AndroidManifest.xml +++ b/WordPress/src/main/AndroidManifest.xml @@ -313,6 +313,11 @@ android:theme="@style/WordPress.NoActionBar" android:launchMode="singleTop" android:label="@string/comments"/> + , String>() + + // Bumped by [clearPostTitles]: a fetch already in flight when the user cleared would + // otherwise re-poison the cache with its pre-clear "not found" results. + @Volatile + private var unresolvedCacheGeneration = 0 + + /** A comment mapped from [CommentWithViewContext], shared by the detail and list screens. */ data class RsComment( + val remoteCommentId: Long, val authorName: String, val authorAvatarUrl: String, val dateGmt: Date, @@ -44,6 +65,17 @@ class CommentsRsDataSource @Inject constructor( data class Error(val message: String?) : RsResult } + /** Result of fetching one page of the comment list. */ + sealed interface RsCommentsPageResult { + data class Success( + val comments: List, + /** Ready-made params for the next page; null when this was the last page. */ + val nextPageParams: CommentListParams? + ) : RsCommentsPageResult + + data class Error(val message: String?) : RsCommentsPageResult + } + suspend fun getComment(site: SiteModel, commentId: Long): RsComment? = safe(errorValue = null) { val client = wpApiClientProvider.getWpApiClient(site) when ( @@ -56,6 +88,121 @@ class CommentsRsDataSource @Inject constructor( } } + /** + * Fetches one page of the site's comments. Pass [firstPageParams] for the first page and the + * previous page's [RsCommentsPageResult.Success.nextPageParams] for subsequent pages. + */ + suspend fun fetchCommentsPage(site: SiteModel, params: CommentListParams): RsCommentsPageResult = + safe(errorValue = RsCommentsPageResult.Error(null)) { + val client = wpApiClientProvider.getWpApiClient(site) + when (val result = client.request { it.comments().listWithViewContext(params) }) { + is WpRequestResult.Success -> RsCommentsPageResult.Success( + comments = result.response.data.map { it.toRsComment() }, + nextPageParams = result.response.nextPageParams + ) + is WpRequestResult.WpError -> RsCommentsPageResult.Error(result.errorMessage) + else -> RsCommentsPageResult.Error(null) + } + } + + fun firstPageParams(status: RsCommentStatus?, search: String? = null): CommentListParams = + CommentListParams( + perPage = COMMENTS_PAGE_SIZE, + search = search, + status = status, + orderby = WpApiParamCommentsOrderBy.DATE_GMT, + order = WpApiParamOrder.DESC + ) + + /** + * Fetches post titles for the given [postIds] in batched sparse-field network calls, returning + * a map of post id to rendered title. Ids already cached skip the network round-trip. + * (List rows show "author commented on {post}", but the comment payload only has the post id.) + * + * Comments can be attached to posts or pages, so unresolved ids fall back to the pages + * endpoint. Ids neither endpoint returns (custom post types, non-published posts) resolve to + * an empty title and are negative-cached so they aren't re-requested on every page load. + */ + suspend fun fetchPostTitles(site: SiteModel, postIds: List): Map { + val result = mutableMapOf() + val uncached = mutableListOf() + for (id in postIds.distinct()) { + val cached = postTitleCache[site.id to id] + if (cached != null) result[id] = cached else uncached.add(id) + } + if (uncached.isEmpty()) return result + + safe(errorValue = Unit) { + val generation = unresolvedCacheGeneration + val postsSucceeded = fetchTitlesFromEndpoint(site, PostEndpointType.Posts, uncached, result) + val remaining = uncached.filter { it !in result } + val pagesSucceeded = if (remaining.isEmpty()) { + true + } else { + fetchTitlesFromEndpoint(site, PostEndpointType.Pages, remaining, result) + } + // Only when both endpoints succeeded are the leftover ids genuinely unresolvable + // (custom post types, non-published posts) rather than a transient failure — and + // only when no clear happened mid-flight, so pre-clear results can't re-poison it. + if (postsSucceeded && pagesSucceeded && generation == unresolvedCacheGeneration) { + for (id in uncached.filter { it !in result }) { + postTitleCache[site.id to id] = "" + result[id] = "" + } + } + } + return result + } + + /** + * Forgets all cached titles for [site] so they're re-fetched, e.g. on a user-initiated + * refresh — a post that wasn't published when first seen may be by now, and a resolved + * title may have been renamed since it was cached. Fetches are batched, so re-resolving a + * page of titles costs a single request. + */ + fun clearPostTitles(site: SiteModel) { + unresolvedCacheGeneration++ + postTitleCache.entries.removeIf { it.key.first == site.id } + } + + /** + * Fetches titles for [ids] from [endpointType] into [into] (and the cache), returning whether + * every request succeeded. [PostListParams.perPage] is set explicitly (the server default of + * 10 would silently truncate larger batches) and ids are chunked to the server's per_page + * maximum of 100 (a larger value is rejected outright with `rest_invalid_param`). + */ + private suspend fun fetchTitlesFromEndpoint( + site: SiteModel, + endpointType: PostEndpointType, + ids: List, + into: MutableMap + ): Boolean { + val client = wpApiClientProvider.getWpApiClient(site) + var allSucceeded = true + for (chunk in ids.chunked(MAX_TITLES_PER_REQUEST)) { + val response = client.request { + it.posts().filterListWithViewContext( + endpointType, + PostListParams(include = chunk, perPage = chunk.size.toUInt()), + listOf(SparseAnyPostFieldWithViewContext.ID, SparseAnyPostFieldWithViewContext.TITLE) + ) + } + if (response is WpRequestResult.Success) { + for (post in response.response.data) { + val id = post.id ?: continue + val title = post.title?.rendered.orEmpty() + postTitleCache[site.id to id] = title + into[id] = title + } + } else { + val message = (response as? WpRequestResult.WpError<*>)?.errorMessage + AppLog.w(AppLog.T.COMMENTS, "fetchPostTitles ($endpointType) failed: $message") + allSucceeded = false + } + } + return allSucceeded + } + suspend fun updateStatus(site: SiteModel, commentId: Long, status: CommentStatus): RsResult = write(site) { it.comments().update(commentId, CommentUpdateParams(status = status.toRsCommentStatus())) } @@ -91,20 +238,28 @@ class CommentsRsDataSource @Inject constructor( errorValue } - private fun CommentWithViewContext.toRsComment() = RsComment( - authorName = authorName, - authorAvatarUrl = (authorAvatarUrls[UserAvatarSize.Size96] - ?: authorAvatarUrls.values.firstOrNull { !it.isNullOrEmpty() }).orEmpty(), - // dateGmt is a UTC java.util.Date (an absolute instant), so relative-time formatting is - // correct regardless of the site's timezone — unlike the offset-less local `date` field. - dateGmt = dateGmt, - contentHtml = content.rendered, - url = link, - postId = post, - status = status.toAppCommentStatus() - ) + companion object { + internal const val COMMENTS_PAGE_SIZE = 30u + private const val MAX_TITLES_PER_REQUEST = 100 + } } +internal fun CommentWithViewContext.toRsComment() = CommentsRsDataSource.RsComment( + remoteCommentId = id, + authorName = authorName, + authorAvatarUrl = pickAvatarUrl(), + // dateGmt is a UTC java.util.Date (an absolute instant), so relative-time formatting is + // correct regardless of the site's timezone — unlike the offset-less local `date` field. + dateGmt = dateGmt, + contentHtml = content.rendered, + url = link, + postId = post, + status = status.toAppCommentStatus() +) + +internal fun CommentWithViewContext.pickAvatarUrl(): String = + (authorAvatarUrls[UserAvatarSize.Size96] ?: authorAvatarUrls.values.firstOrNull { !it.isNullOrEmpty() }).orEmpty() + internal fun CommentStatus.toRsCommentStatus(): RsCommentStatus = when (this) { CommentStatus.APPROVED -> RsCommentStatus.Approved CommentStatus.UNAPPROVED -> RsCommentStatus.Hold diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsFragment.kt index d5870313a1ab..15e62225be12 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsFragment.kt @@ -102,7 +102,7 @@ class UnifiedCommentDetailsFragment : private val editCommentLauncher: ActivityResultLauncher = registerForActivityResult(StartActivityForResult()) { result -> if (result.resultCode == AppCompatActivity.RESULT_OK) { - viewModel.refreshComment() + viewModel.onCommentEdited() } } @@ -234,6 +234,12 @@ class UnifiedCommentDetailsFragment : } viewModel.onSnackbarMessage.observeEvent(viewLifecycleOwner) { showSnackbar(it) } + + // Report the change to the launching screen (the rs comments list only refreshes when + // the result says something actually changed). + viewModel.commentChanged.observeEvent(viewLifecycleOwner) { + requireActivity().setResult(AppCompatActivity.RESULT_OK) + } } private fun clearReplyInput() { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsViewModel.kt index f1cff717dadc..22f8d361d34d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentDetailsViewModel.kt @@ -60,11 +60,19 @@ class UnifiedCommentDetailsViewModel @Inject constructor( private val _uiState = MutableLiveData() private val _uiActionEvent = MutableLiveData>() private val _onSnackbarMessage = MutableLiveData>() + private val _commentChanged = MutableLiveData>() val uiState: LiveData = _uiState val uiActionEvent: LiveData> = _uiActionEvent val onSnackbarMessage: LiveData> = _onSnackbarMessage + /** + * Fires when the comment was changed on the server (moderated, replied to, edited or + * deleted) — NOT on like, which the list doesn't render. The host activity reports it as + * its result so the rs list only refreshes when there's something new to show. + */ + val commentChanged: LiveData> = _commentChanged + private var isStarted = false private lateinit var site: SiteModel private var remoteCommentId: Long = 0 @@ -83,10 +91,12 @@ class UnifiedCommentDetailsViewModel @Inject constructor( } /** - * Reloads the comment. Used after returning from the edit screen so any edits are reflected. + * The edit screen reported saved changes: mark the comment as changed and reload it so the + * edits are reflected. */ - fun refreshComment() { + fun onCommentEdited() { if (!isStarted) return + _commentChanged.value = Event(Unit) loadComment() } @@ -100,16 +110,32 @@ class UnifiedCommentDetailsViewModel @Inject constructor( if (!isRefresh) { _uiState.value = CommentDetailsUiState(showProgress = true) } - val (rsComment, cached) = withContext(bgDispatcher) { + val (rsComment, cached, fallbackPostTitle) = withContext(bgDispatcher) { val rs = commentsRsDataSource.getComment(site, remoteCommentId) - val local = commentsStore.getCommentByLocalSiteAndRemoteId(site.id, remoteCommentId).firstOrNull() - rs to local + var local = commentsStore.getCommentByLocalSiteAndRemoteId(site.id, remoteCommentId).firstOrNull() + // Opened from the rs list the FluxC cache may not have this comment at all (the + // legacy list guaranteed a row before the detail could open). Fetch it so the + // post title, like state and the edit screen's local id are available — WP.com + // only, since FluxC has no application-password transport. + if (local == null && site.isUsingWpComRestApi) { + commentsStore.fetchComment(site, remoteCommentId, null) + local = commentsStore.getCommentByLocalSiteAndRemoteId(site.id, remoteCommentId).firstOrNull() + } + // Still no title (self-hosted application-password site, or the fetch failed): + // resolve it the way the rs list does — usually a free hit on the shared title + // cache the list populated moments earlier. + val fallbackTitle = if (local?.postTitle.isNullOrBlank() && rs != null && rs.postId > 0) { + commentsRsDataSource.fetchPostTitles(site, listOf(rs.postId))[rs.postId].orEmpty() + } else { + "" + } + Triple(rs, local, fallbackTitle) } when { rsComment != null -> { loadedComment = rsComment localCommentId = cached?.id?.toInt() ?: 0 - _uiState.value = rsComment.toUiState(cached) + _uiState.value = rsComment.toUiState(cached, fallbackPostTitle) } isRefresh -> showSnackbar(R.string.error_load_comment) else -> _onSnackbarMessage.value = Event( @@ -205,6 +231,7 @@ class UnifiedCommentDetailsViewModel @Inject constructor( if (result is RsResult.Error) { showError(result.message, R.string.error_generic) } else { + _commentChanged.value = Event(Unit) // Replying to an unapproved comment implicitly approves it, matching legacy behaviour if (currentStatus() == UNAPPROVED) { approveAfterReply() @@ -236,8 +263,11 @@ class UnifiedCommentDetailsViewModel @Inject constructor( if (result is RsResult.Error) { _uiState.value = _uiState.value?.copy(status = previousStatus) showError(result.message, R.string.error_moderate_comment) - } else if (closeOnSuccess) { - _uiActionEvent.value = Event(Close) + } else { + _commentChanged.value = Event(Unit) + if (closeOnSuccess) { + _uiActionEvent.value = Event(Close) + } } } } @@ -284,14 +314,14 @@ class UnifiedCommentDetailsViewModel @Inject constructor( _onSnackbarMessage.value = Event(SnackbarMessageHolder(uiMessage)) } - private fun RsComment.toUiState(cached: CommentEntity?) = CommentDetailsUiState( + private fun RsComment.toUiState(cached: CommentEntity?, fallbackPostTitle: String) = CommentDetailsUiState( showProgress = false, contentVisible = true, authorName = authorName, authorAvatarUrl = authorAvatarUrl, datePublished = dateTimeUtilsWrapper.javaDateToTimeSpan(dateGmt), commentText = contentHtml, - postTitle = cached?.postTitle ?: "", + postTitle = cached?.postTitle?.takeIf { it.isNotBlank() } ?: fallbackPostTitle, commentUrl = url, status = status, isLiked = cached?.iLike ?: false diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentListFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentListFragment.kt index e433ac99dc3a..9893a0f21d35 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentListFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentListFragment.kt @@ -15,7 +15,6 @@ import org.wordpress.android.R import org.wordpress.android.WordPress import org.wordpress.android.databinding.UnifiedCommentListFragmentBinding import org.wordpress.android.fluxc.model.CommentStatus -import org.wordpress.android.ui.ActivityLauncher import org.wordpress.android.ui.comments.unified.CommentDetailsActivityContract.CommentDetailsActivityRequest import org.wordpress.android.ui.comments.unified.CommentDetailsActivityContract.CommentDetailsActivityResponse import org.wordpress.android.ui.comments.unified.CommentListUiModelHelper.ActionModeUiModel @@ -25,8 +24,6 @@ import org.wordpress.android.ui.comments.unified.CommentListUiModelHelper.Commen import org.wordpress.android.ui.comments.unified.CommentListUiModelHelper.ConfirmationDialogUiModel import org.wordpress.android.ui.comments.unified.CommentListUiModelHelper.ConfirmationDialogUiModel.Visible import org.wordpress.android.ui.mysite.SelectedSiteRepository -import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures -import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures.Feature import org.wordpress.android.ui.utils.UiHelpers import org.wordpress.android.util.NetworkUtilsWrapper import org.wordpress.android.util.SnackbarItem @@ -54,9 +51,6 @@ class UnifiedCommentListFragment : Fragment(R.layout.unified_comment_list_fragme @Inject lateinit var networkUtilsWrapper: NetworkUtilsWrapper - @Inject - lateinit var experimentalFeatures: ExperimentalFeatures - private lateinit var viewModel: UnifiedCommentListViewModel private lateinit var activityViewModel: UnifiedCommentActivityViewModel private lateinit var adapter: UnifiedCommentListAdapter @@ -166,25 +160,16 @@ class UnifiedCommentListFragment : Fragment(R.layout.unified_comment_list_fragme private fun showCommentDetails(commentId: Long, commentStatus: CommentStatus) { currentSnackbar?.dismiss() - val site = selectedSiteRepository.getSelectedSite() - // The rs detail can only authenticate WP.com-accessed or application-password sites, so - // fall back to the legacy detail elsewhere (same gating as the rs pages/posts screens). - val siteSupportsRs = site != null && (site.isUsingWpComRestApi || site.hasApplicationPassword()) - if (siteSupportsRs && experimentalFeatures.isEnabled(Feature.RS_UNIFIED_COMMENTS)) { - ActivityLauncher.viewUnifiedCommentsDetails( - context, - site, - commentId + // This legacy list is only reached when the RS_UNIFIED_COMMENTS flag is off or the site + // can't use wordpress-rs (see ActivityLauncher.viewUnifiedComments), so it always pairs + // with the legacy detail; the rs list launches the rs detail itself. + commentDetails.launch( + CommentDetailsActivityRequest( + commentId, + commentStatus, + selectedSiteRepository.getSelectedSite()!! ) - } else { - commentDetails.launch( - CommentDetailsActivityRequest( - commentId, - commentStatus, - selectedSiteRepository.getSelectedSite()!! - ) - ) - } + ) } val commentDetails = registerForActivityResult( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListActivity.kt new file mode 100644 index 000000000000..c4218f09e1ac --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListActivity.kt @@ -0,0 +1,80 @@ +package org.wordpress.android.ui.commentsrs + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import org.wordpress.android.ui.comments.unified.UnifiedCommentsDetailsActivity +import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.ui.main.BaseAppCompatActivity +import org.wordpress.android.ui.commentsrs.screens.CommentsRsListScreen +import org.wordpress.android.util.ToastUtils +import org.wordpress.android.util.extensions.setContent + +@AndroidEntryPoint +class CommentsRsListActivity : BaseAppCompatActivity() { + private val viewModel: CommentsRsListViewModel by viewModels() + + // The detail reports RESULT_OK when it changed the comment (moderation, reply, edit, + // delete); only then refresh, so a view-only visit keeps the paged lists and scroll + // position intact. + private val detailLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == RESULT_OK) { + viewModel.refreshAllTabs() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + observeEvents() + + setContent { + val tabStates by viewModel.tabStates.collectAsState() + AppThemeM3 { + CommentsRsListScreen( + tabStates = tabStates, + snackbarMessages = viewModel.snackbarMessages, + onInitTab = viewModel::initTab, + onTabChanged = viewModel::onTabChanged, + onRefreshTab = { tab -> viewModel.refreshTab(tab, isUserRefresh = true) }, + onLoadMore = viewModel::loadMore, + onNavigateBack = { onBackPressedDispatcher.onBackPressed() }, + onCommentClick = viewModel::onCommentClick + ) + } + } + } + + private fun observeEvents() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.events.collect { event -> handleEvent(event) } + } + } + } + + private fun handleEvent(event: CommentsRsListEvent) { + when (event) { + is CommentsRsListEvent.OpenCommentDetail -> detailLauncher.launch( + UnifiedCommentsDetailsActivity.createIntent(this, event.site, event.remoteCommentId) + ) + is CommentsRsListEvent.ShowToast -> ToastUtils.showToast(this, event.messageResId) + is CommentsRsListEvent.Finish -> finish() + } + } + + companion object { + fun createIntent(context: Context) = Intent(context, CommentsRsListActivity::class.java) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListEvent.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListEvent.kt new file mode 100644 index 000000000000..9fa94130cac3 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListEvent.kt @@ -0,0 +1,10 @@ +package org.wordpress.android.ui.commentsrs + +import androidx.annotation.StringRes +import org.wordpress.android.fluxc.model.SiteModel + +sealed interface CommentsRsListEvent { + data class OpenCommentDetail(val site: SiteModel, val remoteCommentId: Long) : CommentsRsListEvent + data class ShowToast(@StringRes val messageResId: Int) : CommentsRsListEvent + object Finish : CommentsRsListEvent +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListTab.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListTab.kt new file mode 100644 index 000000000000..fa614ead0885 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListTab.kt @@ -0,0 +1,55 @@ +package org.wordpress.android.ui.commentsrs + +import androidx.annotation.StringRes +import org.wordpress.android.R +import uniffi.wp_api.CommentStatus as RsCommentStatus + +/** + * Filter tabs for the rs comments list, matching the legacy unified list minus Unreplied + * (deferred; it needs client-side threading of ~100 comments). + * + * [queryStatus] is the `status` query param for `/wp/v2/comments`. Two tabs use + * [RsCommentStatus.Custom] because `WP_Comment_Query` only recognises the literal values + * `approve` and `all` — wordpress-rs serialises [RsCommentStatus.Approved] as `approved`, + * which WordPress core treats as an unknown status and returns nothing for (wordpress-rs + * `comments.rs` serialisation test asserts `status=approved`). `all` means approved+hold, + * matching the legacy ALL filter (APPROVED+UNAPPROVED). + */ +enum class CommentsRsListTab( + @StringRes val labelResId: Int, + @StringRes val emptyMessageResId: Int, + /** The `selected_filter` property value for COMMENT_FILTER_CHANGED, matching the legacy list. */ + @StringRes val trackingLabelResId: Int, + val queryStatus: RsCommentStatus +) { + ALL( + R.string.comment_status_all, + R.string.comments_empty_list, + R.string.comment_tracker_label_all, + RsCommentStatus.Custom("all") + ), + PENDING( + R.string.comment_status_unapproved, + R.string.comments_empty_list_filtered_pending, + R.string.comment_tracker_label_pending, + RsCommentStatus.Hold + ), + APPROVED( + R.string.comment_status_approved, + R.string.comments_empty_list_filtered_approved, + R.string.comment_tracker_label_approved, + RsCommentStatus.Custom("approve") + ), + SPAM( + R.string.comment_status_spam, + R.string.comments_empty_list_filtered_spam, + R.string.comment_tracker_label_spam, + RsCommentStatus.Spam + ), + TRASHED( + R.string.comment_status_trash, + R.string.comments_empty_list_filtered_trashed, + R.string.comment_tracker_label_trashed, + RsCommentStatus.Trash + ) +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListUiState.kt new file mode 100644 index 000000000000..758c456cfd56 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListUiState.kt @@ -0,0 +1,27 @@ +package org.wordpress.android.ui.commentsrs + +import org.wordpress.android.fluxc.model.CommentStatus + +/** One comment row. */ +data class CommentRsUiModel( + val remoteCommentId: Long, + val authorName: String, + val avatarUrl: String, + val snippet: String, + val relativeDate: String, + val status: CommentStatus, + val postId: Long, + /** Resolved asynchronously in a batched request; null until then. */ + val postTitle: String? = null +) { + val isPending: Boolean get() = status == CommentStatus.UNAPPROVED +} + +data class CommentsTabUiState( + val comments: List = emptyList(), + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val isLoadingMore: Boolean = false, + val canLoadMore: Boolean = false, + val error: String? = null +) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListViewModel.kt new file mode 100644 index 000000000000..5a52702b0a06 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListViewModel.kt @@ -0,0 +1,316 @@ +package org.wordpress.android.ui.commentsrs + +import androidx.annotation.MainThread +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.wordpress.android.R +import org.wordpress.android.analytics.AnalyticsTracker.Stat +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.modules.BG_THREAD +import org.wordpress.android.ui.comments.unified.CommentsRsDataSource +import org.wordpress.android.ui.comments.unified.CommentsRsDataSource.RsComment +import org.wordpress.android.ui.comments.unified.CommentsRsDataSource.RsCommentsPageResult +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.postsrs.PostRsErrorUtils +import org.wordpress.android.ui.postsrs.SnackbarMessage +import org.wordpress.android.util.DateTimeUtilsWrapper +import org.wordpress.android.util.HtmlUtils +import org.wordpress.android.util.NetworkUtilsWrapper +import org.wordpress.android.util.WPAvatarUtilsWrapper +import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper +import org.wordpress.android.viewmodel.ResourceProvider +import uniffi.wp_api.CommentListParams +import javax.inject.Inject +import javax.inject.Named + +/** + * ViewModel for the wordpress-rs comments list. Unlike the postsrs/pagesrs screens there is no + * rs cache support for comments yet, so each tab pages directly against `/wp/v2/comments`, + * keeping the response's `nextPageParams` as the pagination cursor. + */ +@HiltViewModel +class CommentsRsListViewModel @Inject constructor( + selectedSiteRepository: SelectedSiteRepository, + private val commentsRsDataSource: CommentsRsDataSource, + private val resourceProvider: ResourceProvider, + private val networkUtilsWrapper: NetworkUtilsWrapper, + private val dateTimeUtilsWrapper: DateTimeUtilsWrapper, + private val avatarUtilsWrapper: WPAvatarUtilsWrapper, + private val analyticsTracker: AnalyticsTrackerWrapper, + @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher +) : ViewModel() { + private val _tabStates = MutableStateFlow>(emptyMap()) + val tabStates: StateFlow> = _tabStates.asStateFlow() + + private val _events = Channel(Channel.BUFFERED) + val events = _events.receiveAsFlow() + + private val _snackbarMessages = Channel(Channel.BUFFERED) + val snackbarMessages = _snackbarMessages.receiveAsFlow() + + // Pagination cursors: the next-page params returned with each fetched page, per tab. + private val nextPageParams = mutableMapOf() + + // Bumped whenever a first page is applied. A load-more result whose fetch started before the + // bump paged from a cursor that predates the new page 1, so it's stale and gets discarded. + private val pageGenerations = mutableMapOf() + + // The in-flight first-page fetch per tab, so overlapping refreshes don't duplicate it. + private val firstPageJobs = mutableMapOf() + + private var lastTrackedTab: CommentsRsListTab? = null + + // The in-flight title-resolve job per tab. + private val resolveTitleJobs = mutableMapOf() + + private val _site: SiteModel? = selectedSiteRepository.getSelectedSite() + private val site: SiteModel + get() = requireNotNull(_site) { "No selected site — Activity should have finished" } + + init { + if (_site == null) { + _events.trySend(CommentsRsListEvent.ShowToast(R.string.blog_not_found)) + _events.trySend(CommentsRsListEvent.Finish) + } + } + + /** Loads the first page for [tab] unless it's already initialized. */ + @MainThread + fun initTab(tab: CommentsRsListTab) { + // updateTabUiState inserts the tab synchronously, so this also blocks re-entry while + // the first fetch is in flight. + if (_tabStates.value.containsKey(tab)) return + updateTabUiState(tab) { copy(isLoading = true) } + fetchFirstPage(tab) + } + + /** + * Re-fetches the first page for [tab]. The pull-to-refresh indicator is only shown for a + * user-initiated refresh; silent refreshes (returning from the detail) keep the current + * list on screen until the new page arrives. + */ + @MainThread + fun refreshTab(tab: CommentsRsListTab, isUserRefresh: Boolean = false) { + if (!_tabStates.value.containsKey(tab)) return + // A first page is already on its way (initial load or another refresh); a second fetch + // would only duplicate the request and double-bump the page generation. + if (firstPageJobs[tab]?.isActive == true) return + if (isUserRefresh) { + // An explicit refresh retries every post title for the site: ones that previously + // resolved as unresolvable (e.g. a post published since it was first seen) and ones + // that may have gone stale (e.g. a post renamed since it was cached). + commentsRsDataSource.clearPostTitles(site) + // A resolve job that started before the clear may apply pre-clear titles; cancel it + // so the post-refresh pass fetches fresh results. + resolveTitleJobs[tab]?.cancel() + } + updateTabUiState(tab) { copy(isRefreshing = isUserRefresh, error = null) } + fetchFirstPage(tab, showErrorSnackbar = isUserRefresh) + } + + /** Refreshes every initialized tab; comments move between tabs when moderated. */ + @MainThread + fun refreshAllTabs() { + _tabStates.value.keys.forEach { refreshTab(it) } + } + + /** Loads the next page for [tab] if there is one and nothing else is in flight. */ + @MainThread + fun loadMore(tab: CommentsRsListTab) { + loadMoreInternal(tab, autoAdvanceDepth = 0) + } + + private fun loadMoreInternal(tab: CommentsRsListTab, autoAdvanceDepth: Int) { + val current = getTabUiState(tab) + val params = nextPageParams[tab] + val isBusy = current.isLoading || current.isRefreshing || current.isLoadingMore + if (params == null || isBusy) return + + updateTabUiState(tab) { copy(isLoadingMore = true) } + val generation = pageGenerations[tab] + viewModelScope.launch { + val result = withContext(bgDispatcher) { commentsRsDataSource.fetchCommentsPage(site, params) } + if (pageGenerations[tab] != generation) { + // A refresh replaced the list while this page was in flight, so its cursor is + // stale; appending it would mix old and new pages and corrupt the cursor chain. + updateTabUiState(tab) { copy(isLoadingMore = false) } + return@launch + } + when (result) { + is RsCommentsPageResult.Success -> { + val sizeBefore = getTabUiState(tab).comments.size + applyPage(tab, result, append = true) + // When a page adds no rows (it deduped away after a server-side shift, or + // came back empty mid-shrink), the scroll position hasn't changed so the + // load-more trigger won't re-fire; advance to the next page directly. The + // depth cap keeps a pathological cursor chain (e.g. a proxy ignoring the + // page param) from firing unbounded unattended requests. + if (getTabUiState(tab).comments.size == sizeBefore && + nextPageParams[tab] != null && + autoAdvanceDepth < MAX_AUTO_ADVANCE_PAGES + ) { + loadMoreInternal(tab, autoAdvanceDepth + 1) + } + } + is RsCommentsPageResult.Error -> { + updateTabUiState(tab) { copy(isLoadingMore = false) } + _snackbarMessages.trySend( + SnackbarMessage( + message = errorMessage(result.message), + actionLabel = resourceProvider.getString(R.string.retry), + onAction = { loadMore(tab) } + ) + ) + } + } + } + } + + /** Opens the rs comment detail for the tapped row. */ + @MainThread + fun onCommentClick(remoteCommentId: Long) { + _events.trySend(CommentsRsListEvent.OpenCommentDetail(site, remoteCommentId)) + } + + /** + * Tracks the tab the pager settled on — the same COMMENT_FILTER_CHANGED event (including the + * initial selection) the legacy list emits, so filter metrics stay comparable. + */ + @MainThread + fun onTabChanged(tab: CommentsRsListTab) { + if (tab == lastTrackedTab) return + lastTrackedTab = tab + analyticsTracker.track( + Stat.COMMENT_FILTER_CHANGED, + mapOf(SELECTED_FILTER_PROPERTY to resourceProvider.getString(tab.trackingLabelResId)) + ) + } + + private fun fetchFirstPage(tab: CommentsRsListTab, showErrorSnackbar: Boolean = true) { + firstPageJobs[tab] = viewModelScope.launch { + val params = commentsRsDataSource.firstPageParams(status = tab.queryStatus) + val result = withContext(bgDispatcher) { commentsRsDataSource.fetchCommentsPage(site, params) } + when (result) { + is RsCommentsPageResult.Success -> applyPage(tab, result, append = false) + is RsCommentsPageResult.Error -> onFirstPageError(tab, result.message, showErrorSnackbar) + } + } + } + + /** + * A fetched page: stores the next-page cursor, applies the rows to the tab state, and kicks + * off post-title resolution. [append] is true when the page extends the list (load more) + * rather than replacing it (first page / refresh). + */ + private fun applyPage(tab: CommentsRsListTab, result: RsCommentsPageResult.Success, append: Boolean) { + if (!append) pageGenerations[tab] = (pageGenerations[tab] ?: 0) + 1 + nextPageParams[tab] = result.nextPageParams + val newRows = result.comments.map { it.toUiModel() } + updateTabUiState(tab) { + copy( + // A comment can shift pages if the list changed server-side between requests, + // so dedupe on append. + comments = if (append) (comments + newRows).distinctBy { it.remoteCommentId } else newRows, + isLoading = false, + isRefreshing = false, + // A replacing page must clear isLoadingMore too: a load-more in flight when the + // refresh landed is discarded by the generation check, so nothing else resets + // the flag promptly and paging would stay blocked behind the isBusy guard. + isLoadingMore = false, + canLoadMore = result.nextPageParams != null, + error = null + ) + } + resolvePostTitles(tab) + } + + /** + * First-page failure: when the tab already shows comments keep them and offer a retry + * snackbar; when it's empty, show the full-screen error state. Silent refreshes (returning + * from the detail) skip the snackbar — a failed background refresh shouldn't interrupt a + * user who's still looking at a perfectly good list. + */ + private fun onFirstPageError(tab: CommentsRsListTab, message: String?, showErrorSnackbar: Boolean) { + val friendly = errorMessage(message) + if (getTabUiState(tab).comments.isNotEmpty()) { + updateTabUiState(tab) { copy(isLoading = false, isRefreshing = false, error = null) } + if (showErrorSnackbar) { + _snackbarMessages.trySend( + SnackbarMessage( + message = friendly, + actionLabel = resourceProvider.getString(R.string.retry), + onAction = { refreshTab(tab, isUserRefresh = true) } + ) + ) + } + } else { + updateTabUiState(tab) { + copy(comments = emptyList(), isLoading = false, isRefreshing = false, error = friendly) + } + } + } + + /** + * Resolves the "commented on {post}" titles for rows that don't have one yet, in a single + * batched request per tab. Rows keep an author-only title until (or if ever) resolved. + */ + private fun resolvePostTitles(tab: CommentsRsListTab) { + val unresolvedIds = getTabUiState(tab).comments + .filter { it.postTitle == null && it.postId > 0 } + .map { it.postId } + .distinct() + if (unresolvedIds.isEmpty()) return + + // Cancel-and-relaunch is cheap even when the ids haven't changed: the data source + // caches resolved titles, so a re-fired request skips them. + resolveTitleJobs[tab]?.cancel() + resolveTitleJobs[tab] = viewModelScope.launch { + val titles = withContext(bgDispatcher) { commentsRsDataSource.fetchPostTitles(site, unresolvedIds) } + if (titles.isEmpty()) return@launch + updateTabUiState(tab) { + copy(comments = comments.map { it.copy(postTitle = titles[it.postId] ?: it.postTitle) }) + } + } + } + + /** The server message when it sent one, otherwise a friendly offline/generic message. */ + private fun errorMessage(serverMessage: String?): String = + serverMessage?.takeIf { it.isNotBlank() } + ?: PostRsErrorUtils.friendlyErrorMessage(null, null, resourceProvider, networkUtilsWrapper) + + private fun RsComment.toUiModel() = CommentRsUiModel( + remoteCommentId = remoteCommentId, + authorName = authorName.ifBlank { resourceProvider.getString(R.string.anonymous) }, + avatarUrl = avatarUtilsWrapper.rewriteAvatarUrlWithResource(authorAvatarUrl, R.dimen.avatar_sz_medium), + snippet = HtmlUtils.fastStripHtml(contentHtml).trim(), + relativeDate = dateTimeUtilsWrapper.javaDateToTimeSpan(dateGmt), + status = status, + postId = postId + ) + + private fun getTabUiState(tab: CommentsRsListTab): CommentsTabUiState = + _tabStates.value[tab] ?: CommentsTabUiState(isLoading = true) + + private fun updateTabUiState(tab: CommentsRsListTab, update: CommentsTabUiState.() -> CommentsTabUiState) { + _tabStates.value += (tab to getTabUiState(tab).update()) + } + + companion object { + // How many extra pages a single load-more may fetch unattended when applied pages + // add no rows, before waiting for the next user scroll. + private const val MAX_AUTO_ADVANCE_PAGES = 3 + + // Same property key as the legacy list's tracking (UnifiedCommentsActivity). + private const val SELECTED_FILTER_PROPERTY = "selected_filter" + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListItem.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListItem.kt new file mode 100644 index 000000000000..860c8ebaf760 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListItem.kt @@ -0,0 +1,144 @@ +package org.wordpress.android.ui.commentsrs.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import org.wordpress.android.R +import org.wordpress.android.ui.commentsrs.CommentRsUiModel + +private val AVATAR_SIZE = 40.dp +private val PENDING_INDICATOR_WIDTH = 4.dp + +@Composable +fun CommentsRsListItem( + comment: CommentRsUiModel, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .clickable(onClick = onClick) + ) { + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + // The pending indicator: a colored bar on the row's leading edge, like the legacy + // list's status indicator. + Box( + modifier = Modifier + .width(PENDING_INDICATOR_WIDTH) + .fillMaxHeight() + .background( + if (comment.isPending) { + MaterialTheme.colorScheme.tertiary + } else { + Color.Transparent + } + ) + ) + Row(modifier = Modifier.padding(start = 12.dp, top = 16.dp, end = 16.dp, bottom = 16.dp)) { + CommentAvatar(comment = comment) + Column( + modifier = Modifier + .padding(start = 16.dp) + .weight(1f) + ) { + Text( + text = commentTitle(comment), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (comment.snippet.isNotBlank()) { + Text( + text = comment.snippet, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp) + ) + } + Text( + text = comment.relativeDate, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } + HorizontalDivider(modifier = Modifier.padding(start = 72.dp)) + } +} + +@Composable +private fun CommentAvatar(comment: CommentRsUiModel) { + val fallback = rememberVectorPainter(Icons.Filled.Person) + AsyncImage( + model = comment.avatarUrl.ifBlank { null }, + contentDescription = null, + contentScale = ContentScale.Crop, + fallback = fallback, + error = fallback, + modifier = Modifier + .size(AVATAR_SIZE) + .clip(CircleShape) + ) +} + +/** + * The row title: "{author} on {post title}" via the shared [R.string.comment_title] template with + * both parts bold (like legacy `CommentListUiUtils.formatCommentTitle`), or just the bold author + * name while the post title is unresolved. + */ +@Composable +private fun commentTitle(comment: CommentRsUiModel): AnnotatedString { + val postTitle = comment.postTitle?.trim().orEmpty() + val formatted = if (postTitle.isEmpty()) { + comment.authorName + } else { + stringResource(R.string.comment_title, comment.authorName, postTitle) + } + return buildAnnotatedString { + append(formatted) + boldRange(formatted, comment.authorName) + if (postTitle.isNotEmpty()) boldRange(formatted, postTitle) + } +} + +private fun AnnotatedString.Builder.boldRange(formatted: String, part: String) { + val start = formatted.indexOf(part) + if (start >= 0) { + addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, start + part.length) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListScreen.kt new file mode 100644 index 000000000000..4e53ea7f3f96 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsListScreen.kt @@ -0,0 +1,123 @@ +package org.wordpress.android.ui.commentsrs.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import org.wordpress.android.R +import org.wordpress.android.ui.commentsrs.CommentsRsListTab +import org.wordpress.android.ui.commentsrs.CommentsTabUiState +import org.wordpress.android.ui.postsrs.SnackbarMessage + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CommentsRsListScreen( + tabStates: Map, + snackbarMessages: Flow, + onInitTab: (CommentsRsListTab) -> Unit, + onTabChanged: (CommentsRsListTab) -> Unit, + onRefreshTab: (CommentsRsListTab) -> Unit, + onLoadMore: (CommentsRsListTab) -> Unit, + onNavigateBack: () -> Unit, + onCommentClick: (Long) -> Unit +) { + val tabs = CommentsRsListTab.entries + val pagerState = rememberPagerState(pageCount = { tabs.size }) + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(snackbarMessages) { + snackbarMessages.collect { msg -> + val result = snackbarHostState.showSnackbar( + message = msg.message, + actionLabel = msg.actionLabel + ) + if (result == SnackbarResult.ActionPerformed) { + msg.onAction?.invoke() + } + } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text(text = stringResource(R.string.comments)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + } + ) + } + ) { contentPadding -> + Column(modifier = Modifier.fillMaxSize().padding(contentPadding)) { + PrimaryScrollableTabRow( + selectedTabIndex = pagerState.settledPage, + edgePadding = 0.dp + ) { + tabs.forEachIndexed { index, tab -> + Tab( + selected = pagerState.settledPage == index, + onClick = { + coroutineScope.launch { pagerState.animateScrollToPage(index) } + }, + unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + text = { Text(text = stringResource(tab.labelResId)) } + ) + } + } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage }.collect { page -> + onInitTab(tabs[page]) + onTabChanged(tabs[page]) + } + } + + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f) + ) { page -> + val tab = tabs[page] + val tabState = tabStates[tab] ?: CommentsTabUiState(isLoading = true) + + CommentsRsTabListScreen( + state = tabState, + emptyMessageResId = tab.emptyMessageResId, + onRefresh = { onRefreshTab(tab) }, + onLoadMore = { onLoadMore(tab) }, + onCommentClick = onCommentClick + ) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsTabListScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsTabListScreen.kt new file mode 100644 index 000000000000..ea83ebaf4806 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/screens/CommentsRsTabListScreen.kt @@ -0,0 +1,227 @@ +package org.wordpress.android.ui.commentsrs.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.distinctUntilChanged +import org.wordpress.android.R +import org.wordpress.android.ui.commentsrs.CommentRsUiModel +import org.wordpress.android.ui.commentsrs.CommentsTabUiState +import org.wordpress.android.ui.compose.components.ShimmerBox + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CommentsRsTabListScreen( + state: CommentsTabUiState, + emptyMessageResId: Int, + onRefresh: () -> Unit, + onLoadMore: () -> Unit, + onCommentClick: (Long) -> Unit, + modifier: Modifier = Modifier +) { + val pullToRefreshState = rememberPullToRefreshState() + + PullToRefreshBox( + modifier = modifier.fillMaxSize(), + isRefreshing = state.isRefreshing, + state = pullToRefreshState, + onRefresh = onRefresh, + indicator = { + PullToRefreshDefaults.Indicator( + state = pullToRefreshState, + isRefreshing = state.isRefreshing, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + ) { + when { + state.isLoading -> ShimmerList() + state.error != null && state.comments.isEmpty() -> ErrorContent( + error = state.error, + onRetry = onRefresh + ) + state.comments.isEmpty() && !state.isRefreshing -> EmptyContent( + emptyMessageResId = emptyMessageResId + ) + else -> CommentListContent( + comments = state.comments, + isLoadingMore = state.isLoadingMore, + canLoadMore = state.canLoadMore, + onLoadMore = onLoadMore, + onCommentClick = onCommentClick + ) + } + } +} + +@Composable +private fun CommentListContent( + comments: List, + isLoadingMore: Boolean, + canLoadMore: Boolean, + onLoadMore: () -> Unit, + onCommentClick: (Long) -> Unit +) { + val listState = rememberLazyListState() + + // Also keyed on the list size: a refresh that truncates the list (or an appended page) restarts + // the flow, so a `true` latched by distinctUntilChanged before the change can't suppress the + // re-fire needed to resume paging. The ViewModel's busy/cursor guards make re-fires safe. + LaunchedEffect(canLoadMore, comments.size) { + if (!canLoadMore) return@LaunchedEffect + snapshotFlow { + // total > 0 keeps the pre-layout pass (empty layoutInfo, 0 >= -threshold) from + // triggering a load of the next page before the user has scrolled at all. + val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val total = listState.layoutInfo.totalItemsCount + total > 0 && lastVisible >= total - LOAD_MORE_THRESHOLD + }.distinctUntilChanged().collect { shouldLoad -> + if (shouldLoad) onLoadMore() + } + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize() + ) { + items( + items = comments, + key = { it.remoteCommentId } + ) { comment -> + CommentsRsListItem( + comment = comment, + onClick = { onCommentClick(comment.remoteCommentId) }, + modifier = Modifier.animateItem() + ) + } + + if (isLoadingMore) { + item(key = "loading_more") { + Box( + modifier = Modifier + .fillParentMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } + } + } + } +} + +@Composable +private fun ShimmerList() { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(SHIMMER_ITEM_COUNT) { + PlaceholderItem() + } + } +} + +@Composable +private fun PlaceholderItem() { + Row(modifier = Modifier.padding(16.dp)) { + ShimmerBox( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + ) + Column(modifier = Modifier.padding(start = 16.dp)) { + ShimmerBox( + modifier = Modifier + .size(width = 180.dp, height = 16.dp) + .clip(RoundedCornerShape(4.dp)) + ) + Spacer(modifier = Modifier.height(8.dp)) + ShimmerBox( + modifier = Modifier + .size(width = 260.dp, height = 14.dp) + .clip(RoundedCornerShape(4.dp)) + ) + } + } +} + +@Composable +private fun ErrorContent(error: String, onRetry: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.error_generic), + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text(text = stringResource(R.string.retry)) + } + } +} + +@Composable +private fun EmptyContent(emptyMessageResId: Int) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(emptyMessageResId), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +private const val LOAD_MORE_THRESHOLD = 5 +private const val SHIMMER_ITEM_COUNT = 8 diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/BaseAppCompatActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/main/BaseAppCompatActivity.kt index 3b2bd1551ce3..6225fc81a75d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/main/BaseAppCompatActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/main/BaseAppCompatActivity.kt @@ -17,6 +17,7 @@ import org.wordpress.android.ui.jetpackoverlay.JetpackStaticPosterActivity import org.wordpress.android.ui.main.feedbackform.FeedbackFormActivity import org.wordpress.android.ui.media.MediaPreviewActivity import org.wordpress.android.ui.media.MediaSettingsActivity +import org.wordpress.android.ui.commentsrs.CommentsRsListActivity import org.wordpress.android.ui.mysite.menu.MenuActivity import org.wordpress.android.ui.mysite.personalization.PersonalizationActivity import org.wordpress.android.ui.navmenus.NavMenusActivity @@ -83,6 +84,7 @@ private fun isExcludedActivity(activity: BaseAppCompatActivity) = private val excludedActivities = listOf( BlazeCampaignParentActivity::class.java.name, BloggingPromptsListActivity::class.java.name, + CommentsRsListActivity::class.java.name, DebugSharedPreferenceFlagsActivity::class.java.name, DomainManagementActivity::class.java.name, EditJetpackSocialShareMessageActivity::class.java.name, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeatures.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeatures.kt index c955267e611e..59abcf640bb2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeatures.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeatures.kt @@ -47,8 +47,8 @@ class ExperimentalFeatures @Inject constructor( ), RS_UNIFIED_COMMENTS( "rs_unified_comments", - R.string.experimental_rs_unified_comments, - R.string.experimental_rs_unified_comments_description + R.string.experimental_rs_comments, + R.string.experimental_rs_comments_description ); } } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 2cb702d1ac13..c200d82bd0a2 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1026,8 +1026,8 @@ You can turn off New Stats from the overflow menu in the Stats screen. New Pages List Show pages list in a more modern UI for WordPress.com and self-hosted sites with application passwords - RS Unified Comment Detail - Show the comment detail in a more modern UI for WordPress.com and self-hosted sites with application passwords + RS Unified Comments + Show comments in a more modern UI for WordPress.com and self-hosted sites with application passwords Failed to load post Failed to load page Copy URL diff --git a/WordPress/src/test/java/org/wordpress/android/ui/comments/viewmodels/UnifiedCommentDetailsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/comments/viewmodels/UnifiedCommentDetailsViewModelTest.kt index 30aa5d19eb25..9880c54ce1d1 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/comments/viewmodels/UnifiedCommentDetailsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/comments/viewmodels/UnifiedCommentDetailsViewModelTest.kt @@ -120,7 +120,7 @@ class UnifiedCommentDetailsViewModelTest : BaseUnitTest() { viewModel.start(site, REMOTE_COMMENT_ID) whenever(commentsRsDataSource.getComment(site, REMOTE_COMMENT_ID)).thenReturn(null) - viewModel.refreshComment() + viewModel.onCommentEdited() assertThat(uiStates.last().contentVisible).isTrue assertThat(uiStates.last().status).isEqualTo(APPROVED) @@ -135,7 +135,7 @@ class UnifiedCommentDetailsViewModelTest : BaseUnitTest() { delay(LOAD_DELAY_MS) RS_COMMENT } - viewModel.refreshComment() + viewModel.onCommentEdited() viewModel.onApproveClicked() advanceUntilIdle() @@ -376,6 +376,7 @@ class UnifiedCommentDetailsViewModelTest : BaseUnitTest() { private const val LOAD_DELAY_MS = 1000L private val RS_COMMENT = RsComment( + remoteCommentId = REMOTE_COMMENT_ID, authorName = "authorName", authorAvatarUrl = "", dateGmt = Date(0),