Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions WordPress/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,11 @@
android:theme="@style/WordPress.NoActionBar"
android:launchMode="singleTop"
android:label="@string/comments"/>
<activity
android:name=".ui.commentsrs.CommentsRsListActivity"
android:theme="@style/WordPress.NoActionBar"
android:launchMode="singleTop"
android:label="@string/comments"/>
<activity
android:name=".ui.comments.unified.UnifiedCommentsEditActivity"
android:theme="@style/WordPress.NoActionBar"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
import org.wordpress.android.ui.blaze.blazepromote.BlazePromoteParentActivity;
import org.wordpress.android.ui.bloggingprompts.promptslist.BloggingPromptsListActivity;
import org.wordpress.android.ui.comments.unified.UnifiedCommentsActivity;
import org.wordpress.android.ui.comments.unified.UnifiedCommentsDetailsActivity;
import org.wordpress.android.ui.commentsrs.CommentsRsListActivity;
import org.wordpress.android.ui.debug.cookies.DebugCookiesActivity;
import org.wordpress.android.ui.debug.preferences.DebugSharedPreferenceFlagsActivity;
import org.wordpress.android.ui.domains.DomainRegistrationActivity;
Expand Down Expand Up @@ -744,15 +744,28 @@ public static void viewPageParentForResult(@NonNull Fragment fragment, @NonNull
}

public static void viewUnifiedComments(Context context, SiteModel site) {
Intent intent = new Intent(context, UnifiedCommentsActivity.class);
intent.putExtra(WordPress.SITE, site);
Intent intent;
if (shouldUseRsCommentsList(context, site)) {
intent = CommentsRsListActivity.Companion.createIntent(context);
} else {
intent = new Intent(context, UnifiedCommentsActivity.class);
intent.putExtra(WordPress.SITE, site);
}
context.startActivity(intent);
AnalyticsUtils.trackWithSiteDetails(AnalyticsTracker.Stat.OPENED_COMMENTS, site);
}

public static void viewUnifiedCommentsDetails(Context context, SiteModel site, long remoteCommentId) {
Intent intent = UnifiedCommentsDetailsActivity.createIntent(context, site, remoteCommentId);
context.startActivity(intent);
private static boolean shouldUseRsCommentsList(@NonNull Context context, @NonNull SiteModel site) {
// The rs client can only authenticate WP.com-accessed sites or self-hosted sites with an
// application password; everywhere else keep the legacy (FluxC) comments list.
if (!site.isUsingWpComRestApi() && !site.hasApplicationPassword()) {
return false;
}
ActivityLauncherEntryPoint entryPoint = EntryPointAccessors.fromApplication(
context.getApplicationContext(),
ActivityLauncherEntryPoint.class
);
return entryPoint.experimentalFeatures().isEnabled(Feature.RS_UNIFIED_COMMENTS);
}

public static void viewCurrentBlogThemes(Context context, SiteModel site) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,48 @@ import org.wordpress.android.util.AppLog
import rs.wordpress.api.kotlin.WpRequestResult
import uniffi.wp_api.CommentCreateParams
import uniffi.wp_api.CommentDeleteParams
import uniffi.wp_api.CommentListParams
import uniffi.wp_api.CommentRetrieveParams
import uniffi.wp_api.CommentUpdateParams
import uniffi.wp_api.CommentWithViewContext
import uniffi.wp_api.PostEndpointType
import uniffi.wp_api.PostListParams
import uniffi.wp_api.SparseAnyPostFieldWithViewContext
import uniffi.wp_api.UniffiWpApiClient
import uniffi.wp_api.UserAvatarSize
import uniffi.wp_api.WpApiParamCommentsOrderBy
import uniffi.wp_api.WpApiParamOrder
import java.util.Date
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import uniffi.wp_api.CommentStatus as RsCommentStatus

/**
* Comment CRUD against the wordpress-rs `/wp/v2/comments` endpoint, used by the unified comment
* detail screen. Works on WP.com and self-hosted application-password sites — the same sites the
* postsrs/pagesrs screens support.
* Comment CRUD and list paging against the wordpress-rs `/wp/v2/comments` endpoint, used by the
* unified comment detail and rs comment list screens. Works on WP.com and self-hosted
* application-password sites — the same sites the postsrs/pagesrs screens support.
*
* Liking is intentionally NOT here: wordpress-rs has no comment like action, so the detail screen
* keeps using FluxC for likes (and for keeping the FluxC-backed comment list cache in sync).
*/
@Singleton
class CommentsRsDataSource @Inject constructor(
private val wpApiClientProvider: WpApiClientProvider
) {
// Keyed by (local site id, post id): post ids are only unique per site, and this is a
// singleton shared across site switches. An empty value is a negative-cache entry for ids
// whose title can't be resolved (custom post types, non-published posts).
private val postTitleCache = ConcurrentHashMap<Pair<Int, Long>, 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,
Expand All @@ -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<RsComment>,
/** 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 (
Expand All @@ -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<Long>): Map<Long, String> {
val result = mutableMapOf<Long, String>()
val uncached = mutableListOf<Long>()
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<Long>,
into: MutableMap<Long, String>
): 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())) }

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ class UnifiedCommentDetailsFragment :
private val editCommentLauncher: ActivityResultLauncher<Intent> =
registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == AppCompatActivity.RESULT_OK) {
viewModel.refreshComment()
viewModel.onCommentEdited()
}
}

Expand Down Expand Up @@ -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() {
Expand Down
Loading
Loading