Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2fc6f16
Add wordpress-rs comments list (Phase 2a: core browsing)
nbradbury Jul 2, 2026
aad42b4
Rename RS comments experimental flag to cover the whole comments expe…
nbradbury Jul 2, 2026
a2cc6b3
Simplify rs comments list: drop dead/unused state, share page handlin…
nbradbury Jul 2, 2026
3cd35f7
Use new string keys for the renamed experimental flag copy
nbradbury Jul 2, 2026
b1bf058
Harden rs comments list paging and post-title resolution
nbradbury Jul 2, 2026
8266317
Address review findings in rs comments paging hardening
nbradbury Jul 2, 2026
ca271c8
Close title-resolve race windows and strengthen data source tests
nbradbury Jul 2, 2026
e666ee4
Construct uniffi data classes in tests instead of mocking them
nbradbury Jul 2, 2026
89e2b61
Fix review findings and slim the list PR
nbradbury Jul 3, 2026
0c05ef5
Merge remote-tracking branch 'origin/trunk' into feature/rs-comments-…
nbradbury Jul 3, 2026
0c828e9
Simplify review-pass leftovers
nbradbury Jul 3, 2026
0f0c47d
Merge remote-tracking branch 'origin/trunk' into feature/rs-comments-…
nbradbury Jul 3, 2026
8a944bc
Add batch moderation to the rs comments list (Phase 2b)
nbradbury Jul 2, 2026
8d90ca5
Add search to the rs comments list (Phase 2c)
nbradbury Jul 2, 2026
e6bdeac
Merge remote-tracking branch 'origin/trunk' into feature/rs-comments-…
nbradbury Jul 7, 2026
44a5686
Fix search review findings: clear races, scoping, scroll and focus
nbradbury Jul 7, 2026
68ae990
Address second-pass review findings on comments list search
nbradbury Jul 7, 2026
5296d0d
Address third-pass review findings on comments list search
nbradbury Jul 7, 2026
1e1baf6
Remove stray blank line before brace (checkstyle)
nbradbury Jul 7, 2026
7afff50
Add note mode to the rs comment detail ViewModel
nbradbury Jul 8, 2026
e7e197b
Support notification args and result extras in the rs comment detail
nbradbury Jul 8, 2026
f0990ed
Serve comment notifications with the rs comment detail when gated
nbradbury Jul 8, 2026
7fd68b2
Merge remote-tracking branch 'origin/trunk' into feature/rs-comments-…
nbradbury Jul 8, 2026
0f4f9f3
Give the rs comment detail a surface background
nbradbury Jul 8, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ class NotificationsTableWrapper @Inject constructor() {
fun saveNotes(notes: List<Note>, clearBeforeSaving: Boolean) {
NotificationsTable.saveNotes(notes, clearBeforeSaving)
}

fun getNoteById(noteId: String): Note? = NotificationsTable.getNoteById(noteId)
}
Original file line number Diff line number Diff line change
Expand Up @@ -745,7 +745,7 @@ public static void viewPageParentForResult(@NonNull Fragment fragment, @NonNull

public static void viewUnifiedComments(Context context, SiteModel site) {
Intent intent;
if (shouldUseRsCommentsList(context, site)) {
if (shouldUseRsComments(context, site)) {
intent = CommentsRsListActivity.Companion.createIntent(context);
} else {
intent = new Intent(context, UnifiedCommentsActivity.class);
Expand All @@ -755,9 +755,13 @@ public static void viewUnifiedComments(Context context, SiteModel site) {
AnalyticsUtils.trackWithSiteDetails(AnalyticsTracker.Stat.OPENED_COMMENTS, site);
}

private static boolean shouldUseRsCommentsList(@NonNull Context context, @NonNull SiteModel site) {
/**
* Whether the wordpress-rs comments screens (list, and the notification-hosted detail) should
* be used for {@code site} instead of the legacy (FluxC) ones.
*/
public static boolean shouldUseRsComments(@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.
// application password; everywhere else keep the legacy (FluxC) comments screens.
if (!site.isUsingWpComRestApi() && !site.hasApplicationPassword()) {
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@ import org.wordpress.android.fluxc.model.CommentStatus.UNAPPROVED
import org.wordpress.android.fluxc.model.SiteModel
import org.wordpress.android.ui.CollapseFullScreenDialogFragment
import org.wordpress.android.ui.CommentFullScreenDialogFragment
import org.wordpress.android.ui.ScrollableViewInitializedListener
import org.wordpress.android.ui.comments.unified.CommentDetailsActionEvent.Close
import org.wordpress.android.ui.comments.unified.CommentDetailsActionEvent.LaunchEditComment
import org.wordpress.android.ui.comments.unified.CommentDetailsActionEvent.OpenPostInReader
import org.wordpress.android.ui.comments.unified.CommentDetailsActionEvent.ReplySent
import org.wordpress.android.ui.comments.unified.UnifiedCommentDetailsViewModel.CommentDetailsUiState
import org.wordpress.android.ui.notifications.NotificationsListFragment
import org.wordpress.android.ui.pages.SnackbarMessageHolder
import org.wordpress.android.ui.reader.ReaderActivityLauncher
import org.wordpress.android.ui.suggestion.util.SuggestionServiceConnectionManager
Expand All @@ -57,6 +59,7 @@ import org.wordpress.android.util.SnackbarItem.Info
import org.wordpress.android.util.SnackbarSequencer
import org.wordpress.android.util.ToastUtils
import org.wordpress.android.util.WPLinkMovementMethod
import org.wordpress.android.util.extensions.focusAndShowKeyboard
import org.wordpress.android.util.extensions.getColorFromAttribute
import org.wordpress.android.util.extensions.getColorResIdFromAttribute
import org.wordpress.android.util.extensions.getSerializableCompat
Expand Down Expand Up @@ -89,8 +92,13 @@ class UnifiedCommentDetailsFragment :

private lateinit var site: SiteModel
private var remoteCommentId: Long = 0
private var noteId: String? = null
private var suggestionServiceConnectionManager: SuggestionServiceConnectionManager? = null

// The result reported to the launching screen, accumulated across observers: moderation adds
// the note extras the notifications list uses to update the moderated note's row.
private val resultIntent = Intent()

// The comment HTML currently rendered, so transient state changes (like/reply progress/status)
// don't re-parse the body — which is expensive and resets the user's text selection.
private var renderedCommentHtml: String? = null
Expand All @@ -117,16 +125,20 @@ class UnifiedCommentDetailsFragment :

site = requireNotNull(arguments?.getSerializableCompat<SiteModel>(WordPress.SITE))
remoteCommentId = requireArguments().getLong(KEY_REMOTE_COMMENT_ID)
noteId = arguments?.getString(KEY_NOTE_ID)

UnifiedCommentDetailsFragmentBinding.bind(view).apply {
binding = this
setupClickListeners()
layoutCommentBox.setupReplyBox()
layoutCommentBox.setupReplyBox(isFreshView = savedInstanceState == null)
layoutCommentBox.setupSuggestions()
setupObservers()
// Lets the notifications host lift its app bar with the comment's scroll position;
// the rs comments list host doesn't implement the listener, so this is a no-op there.
(activity as? ScrollableViewInitializedListener)?.onScrollableViewInitialized(scrollView.id)
}

viewModel.start(site, remoteCommentId)
viewModel.start(site, remoteCommentId, noteId)
}

private fun UnifiedCommentDetailsFragmentBinding.setupClickListeners() {
Expand All @@ -144,7 +156,7 @@ class UnifiedCommentDetailsFragment :
if (SiteUtils.isAccessedViaWPComRest(site)) View.VISIBLE else View.GONE
}

private fun ReaderIncludeCommentBoxBinding.setupReplyBox() {
private fun ReaderIncludeCommentBoxBinding.setupReplyBox(isFreshView: Boolean) {
layoutContainer.visibility = View.VISIBLE
editComment.initializeWithPrefix('@')
editComment.doAfterTextChanged { btnSubmitReply.isEnabled = !it.isNullOrBlank() }
Expand All @@ -154,6 +166,16 @@ class UnifiedCommentDetailsFragment :
// application-password sites, which would collide drafts across sites.
editComment.autoSaveTextHelper.uniqueId = "${site.id}-$remoteCommentId"
editComment.autoSaveTextHelper.loadString(editComment)
// Prefill from the notification's inline-reply text, but never clobber an autosaved draft.
val prefill = arguments?.getString(KEY_PREFILL_REPLY_TEXT)
if (editComment.text.isNullOrEmpty() && !prefill.isNullOrEmpty()) {
editComment.setText(prefill)
}
// Opened via the notification's "reply" action: focus the reply field right away, like the
// legacy detail. Only on the first creation — not again after a rotation.
if (isFreshView && arguments?.getBoolean(KEY_FOCUS_REPLY_FIELD) == true) {
editComment.focusAndShowKeyboard()
}
}

private fun ReaderIncludeCommentBoxBinding.setupSuggestions() {
Expand Down Expand Up @@ -238,7 +260,17 @@ class UnifiedCommentDetailsFragment :
// 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)
requireActivity().setResult(AppCompatActivity.RESULT_OK, resultIntent)
}

// Note mode: attach the extras the notifications list uses to update the moderated
// note's row without waiting for its next refresh.
viewModel.commentModerated.observeEvent(viewLifecycleOwner) { status ->
noteId?.let { id ->
resultIntent.putExtra(NotificationsListFragment.NOTE_MODERATE_ID_EXTRA, id)
resultIntent.putExtra(NotificationsListFragment.NOTE_MODERATE_STATUS_EXTRA, status.toString())
requireActivity().setResult(AppCompatActivity.RESULT_OK, resultIntent)
}
}
}

Expand Down Expand Up @@ -491,7 +523,11 @@ class UnifiedCommentDetailsFragment :

companion object {
private const val KEY_REMOTE_COMMENT_ID = "key_remote_comment_id"
private const val KEY_NOTE_ID = "key_note_id"
private const val KEY_PREFILL_REPLY_TEXT = "key_prefill_reply_text"
private const val KEY_FOCUS_REPLY_FIELD = "key_focus_reply_field"

@JvmStatic
fun newInstance(site: SiteModel, remoteCommentId: Long): UnifiedCommentDetailsFragment {
return UnifiedCommentDetailsFragment().apply {
arguments = Bundle().apply {
Expand All @@ -500,5 +536,27 @@ class UnifiedCommentDetailsFragment :
}
}
}

/**
* Factory for the notifications host: [noteId] puts the screen in note mode (see
* [UnifiedCommentDetailsViewModel]), [prefillReplyText] carries the notification's
* inline-reply text and [focusReplyField] opens the keyboard on the reply field.
*/
@JvmStatic
fun newInstance(
site: SiteModel,
remoteCommentId: Long,
noteId: String,
prefillReplyText: String?,
focusReplyField: Boolean
): UnifiedCommentDetailsFragment {
return newInstance(site, remoteCommentId).apply {
requireArguments().apply {
putString(KEY_NOTE_ID, noteId)
putString(KEY_PREFILL_REPLY_TEXT, prefillReplyText)
putBoolean(KEY_FOCUS_REPLY_FIELD, focusReplyField)
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import org.wordpress.android.R
import org.wordpress.android.datasets.wrappers.NotificationsTableWrapper
import org.wordpress.android.fluxc.model.CommentStatus
import org.wordpress.android.fluxc.model.CommentStatus.APPROVED
import org.wordpress.android.fluxc.model.CommentStatus.DELETED
Expand All @@ -21,9 +22,11 @@ import org.wordpress.android.ui.comments.unified.CommentDetailsActionEvent.Close
import org.wordpress.android.ui.comments.unified.CommentDetailsActionEvent.LaunchEditComment
import org.wordpress.android.ui.comments.unified.CommentDetailsActionEvent.OpenPostInReader
import org.wordpress.android.ui.comments.unified.CommentDetailsActionEvent.ReplySent
import org.wordpress.android.ui.comments.unified.CommentIdentifier.NotificationCommentIdentifier
import org.wordpress.android.ui.comments.unified.CommentIdentifier.SiteCommentIdentifier
import org.wordpress.android.ui.comments.unified.CommentsRsDataSource.RsComment
import org.wordpress.android.ui.comments.unified.CommentsRsDataSource.RsResult
import org.wordpress.android.ui.notifications.utils.NotificationsActionsWrapper
import org.wordpress.android.ui.pages.SnackbarMessageHolder
import org.wordpress.android.ui.utils.UiString
import org.wordpress.android.ui.utils.UiString.UiStringRes
Expand All @@ -36,7 +39,7 @@ import javax.inject.Inject
import javax.inject.Named

/**
* ViewModel for the unified comment detail screen (site-comments path).
* ViewModel for the unified comment detail screen.
*
* Loads and mutates the comment through wordpress-rs ([CommentsRsDataSource]) — view, moderation,
* reply, edit. Two things stay on FluxC because wordpress-rs can't cover them:
Expand All @@ -46,7 +49,11 @@ import javax.inject.Named
* list reflects it. The cache row is also the source of the post title, like state and local id
* used to launch the (still FluxC) edit screen.
*
* Notification-sourced comments are handled in a later phase of the Comments Unification migration.
* **Note mode**: when [start] receives a `noteId` the comment was opened from a notification.
* Every successful write then also refreshes the note DB (so the notifications list stays fresh)
* and moderation additionally fires [commentModerated], which the host turns into the
* notifications-list result extras. Edits use [NotificationCommentIdentifier] so the edit screen
* refreshes the note too.
*/
class UnifiedCommentDetailsViewModel @Inject constructor(
@Named(UI_THREAD) private val mainDispatcher: CoroutineDispatcher,
Expand All @@ -55,12 +62,15 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
private val commentsStore: CommentsStore,
private val localCommentCacheUpdateHandler: LocalCommentCacheUpdateHandler,
private val networkUtilsWrapper: NetworkUtilsWrapper,
private val dateTimeUtilsWrapper: DateTimeUtilsWrapper
private val dateTimeUtilsWrapper: DateTimeUtilsWrapper,
private val notificationsActionsWrapper: NotificationsActionsWrapper,
private val notificationsTableWrapper: NotificationsTableWrapper
) : ScopedViewModel(mainDispatcher) {
private val _uiState = MutableLiveData<CommentDetailsUiState>()
private val _uiActionEvent = MutableLiveData<Event<CommentDetailsActionEvent>>()
private val _onSnackbarMessage = MutableLiveData<Event<SnackbarMessageHolder>>()
private val _commentChanged = MutableLiveData<Event<Unit>>()
private val _commentModerated = MutableLiveData<Event<CommentStatus>>()

val uiState: LiveData<CommentDetailsUiState> = _uiState
val uiActionEvent: LiveData<Event<CommentDetailsActionEvent>> = _uiActionEvent
Expand All @@ -73,20 +83,29 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
*/
val commentChanged: LiveData<Event<Unit>> = _commentChanged

/**
* Fires with the new status when the comment was successfully moderated — note mode only.
* The notifications host reports it via the result extras the notifications list uses to
* update the moderated note's row.
*/
val commentModerated: LiveData<Event<CommentStatus>> = _commentModerated

private var isStarted = false
private lateinit var site: SiteModel
private var remoteCommentId: Long = 0
private var noteId: String? = null
private var loadedComment: RsComment? = null
private var isLikeInProgress = false

// From the FluxC cache row: the local id used to launch the (still FluxC) edit screen.
private var localCommentId: Int = 0

fun start(site: SiteModel, remoteCommentId: Long) {
fun start(site: SiteModel, remoteCommentId: Long, noteId: String? = null) {
if (isStarted) return
isStarted = true
this.site = site
this.remoteCommentId = remoteCommentId
this.noteId = noteId
loadComment()
}

Expand All @@ -110,7 +129,7 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
if (!isRefresh) {
_uiState.value = CommentDetailsUiState(showProgress = true)
}
val (rsComment, cached, fallbackPostTitle) = withContext(bgDispatcher) {
val loaded = withContext(bgDispatcher) {
val rs = commentsRsDataSource.getComment(site, remoteCommentId)
var local = commentsStore.getCommentByLocalSiteAndRemoteId(site.id, remoteCommentId).firstOrNull()
// Opened from the rs list the FluxC cache may not have this comment at all (the
Expand All @@ -129,13 +148,24 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
} else {
""
}
Triple(rs, local, fallbackTitle)
// No cache row to read the like state from (the fetch failed or was skipped): in
// note mode the note itself knows whether the comment is liked.
val likedFallback = if (local == null) {
noteId?.let { notificationsTableWrapper.getNoteById(it)?.hasLikedComment() } ?: false
} else {
false
}
CommentLoadResult(rs, local, fallbackTitle, likedFallback)
}
when {
rsComment != null -> {
loadedComment = rsComment
localCommentId = cached?.id?.toInt() ?: 0
_uiState.value = rsComment.toUiState(cached, fallbackPostTitle)
loaded.rsComment != null -> {
loadedComment = loaded.rsComment
localCommentId = loaded.cached?.id?.toInt() ?: 0
_uiState.value = loaded.rsComment.toUiState(
loaded.cached,
loaded.fallbackPostTitle,
loaded.fallbackIsLiked
)
}
isRefresh -> showSnackbar(R.string.error_load_comment)
else -> _onSnackbarMessage.value = Event(
Expand Down Expand Up @@ -185,17 +215,22 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
_uiState.value = _uiState.value?.copy(isLiked = !isLike)
showSnackbar(R.string.error_generic)
} else {
withContext(bgDispatcher) { localCommentCacheUpdateHandler.requestCommentsUpdate() }
withContext(bgDispatcher) {
localCommentCacheUpdateHandler.requestCommentsUpdate()
refreshNote()
}
}
isLikeInProgress = false
}
}

fun onEditClicked() {
if (loadedComment == null) return
_uiActionEvent.value = Event(
LaunchEditComment(site, SiteCommentIdentifier(localCommentId, remoteCommentId))
)
// In note mode edit through the note identifier so the edit screen refreshes the note DB
// after saving, keeping the notifications list consistent with the edited comment.
val identifier = noteId?.let { NotificationCommentIdentifier(it, remoteCommentId) }
?: SiteCommentIdentifier(localCommentId, remoteCommentId)
_uiActionEvent.value = Event(LaunchEditComment(site, identifier))
}

fun onPostTitleClicked() {
Expand Down Expand Up @@ -247,6 +282,8 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
val result = withContext(bgDispatcher) { moderate(APPROVED) }
if (result is RsResult.Error) {
_uiState.value = _uiState.value?.copy(status = UNAPPROVED)
} else if (noteId != null) {
_commentModerated.value = Event(APPROVED)
}
}

Expand All @@ -265,6 +302,9 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
showError(result.message, R.string.error_moderate_comment)
} else {
_commentChanged.value = Event(Unit)
if (noteId != null) {
_commentModerated.value = Event(newStatus)
}
if (closeOnSuccess) {
_uiActionEvent.value = Event(Close)
}
Expand Down Expand Up @@ -292,10 +332,19 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
commentsStore.moderateCommentLocally(site, remoteCommentId, newStatus)
}
localCommentCacheUpdateHandler.requestCommentsUpdate()
refreshNote()
}
return result
}

/**
* Note mode only: re-downloads the note so the note DB (which feeds the notifications list)
* reflects the change just made to the comment.
*/
private suspend fun refreshNote() {
noteId?.let { notificationsActionsWrapper.downloadNoteAndUpdateDB(it) }
}

private fun currentStatus(): CommentStatus = _uiState.value?.status ?: CommentStatus.ALL

private fun isOffline(): Boolean {
Expand All @@ -314,7 +363,11 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
_onSnackbarMessage.value = Event(SnackbarMessageHolder(uiMessage))
}

private fun RsComment.toUiState(cached: CommentEntity?, fallbackPostTitle: String) = CommentDetailsUiState(
private fun RsComment.toUiState(
cached: CommentEntity?,
fallbackPostTitle: String,
fallbackIsLiked: Boolean
) = CommentDetailsUiState(
showProgress = false,
contentVisible = true,
authorName = authorName,
Expand All @@ -324,7 +377,14 @@ class UnifiedCommentDetailsViewModel @Inject constructor(
postTitle = cached?.postTitle?.takeIf { it.isNotBlank() } ?: fallbackPostTitle,
commentUrl = url,
status = status,
isLiked = cached?.iLike ?: false
isLiked = cached?.iLike ?: fallbackIsLiked
)

private data class CommentLoadResult(
val rsComment: RsComment?,
val cached: CommentEntity?,
val fallbackPostTitle: String,
val fallbackIsLiked: Boolean
)

data class CommentDetailsUiState(
Expand Down
Loading
Loading