diff --git a/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java b/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java index 70dc7be2a0f5..7c9d265446b3 100644 --- a/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java +++ b/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java @@ -16,7 +16,6 @@ import org.wordpress.android.ui.bloggingreminders.BloggingReminderBottomSheetFragment; import org.wordpress.android.ui.bloggingreminders.BloggingReminderTimePicker; import org.wordpress.android.ui.comments.CommentDetailFragment; -import org.wordpress.android.ui.comments.unified.EditCancelDialogFragment; import org.wordpress.android.ui.comments.unified.UnifiedCommentDetailsFragment; import org.wordpress.android.ui.comments.unified.UnifiedCommentListAdapter; import org.wordpress.android.ui.comments.unified.UnifiedCommentListFragment; @@ -489,8 +488,6 @@ public interface AppComponent { void inject(UnifiedCommentsEditFragment object); - void inject(EditCancelDialogFragment object); - void inject(BloggingReminderBottomSheetFragment object); void inject(LocalePickerBottomSheet object); diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/CommentEssentials.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/CommentEssentials.kt index 54d3ee0098fb..f7d84f785d98 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/CommentEssentials.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/CommentEssentials.kt @@ -5,8 +5,7 @@ data class CommentEssentials( val userName: String = "", val commentText: String = "", val userUrl: String = "", - val userEmail: String = "", - val isFromRegisteredUser: Boolean = true + val userEmail: String = "" ) { /** * Checks if this instance of CommentEssentials is valid. An invalid instance should not be used to display data. diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/CommentsRsDataSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/CommentsRsDataSource.kt index 664977463a5c..e25dce457ddb 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/CommentsRsDataSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/CommentsRsDataSource.kt @@ -206,6 +206,63 @@ class CommentsRsDataSource @Inject constructor( suspend fun updateStatus(site: SiteModel, commentId: Long, status: CommentStatus): RsResult = write(site) { it.comments().update(commentId, CommentUpdateParams(status = status.toRsCommentStatus())) } + /** Editable author-identity fields, applied to the comment record for any comment. */ + data class CommentAuthor(val name: String, val email: String, val url: String) + + /** + * The server's post-save state of an edited comment. Mirroring this (rather than the values + * that were sent) into the FluxC cache keeps the cache faithful when the server normalises + * fields — e.g. content passes through KSES filtering. + */ + data class RsEditedComment( + val authorName: String, + val authorEmail: String, + val authorUrl: String, + val contentRaw: String + ) + + /** Result of editing a comment, carrying the server's resulting state on success. */ + sealed interface RsEditResult { + data class Success(val comment: RsEditedComment) : RsEditResult + data class Error(val message: String?) : RsEditResult + } + + /** Edits a comment's body and author identity, returning the state the server stored. */ + suspend fun updateComment( + site: SiteModel, + commentId: Long, + content: String, + author: CommentAuthor + ): RsEditResult = safe(errorValue = RsEditResult.Error(null)) { + val client = wpApiClientProvider.getWpApiClient(site) + val result = client.request { + it.comments().update( + commentId, + CommentUpdateParams( + content = content, + authorName = author.name, + authorEmail = author.email, + authorUrl = author.url + ) + ) + } + when (result) { + is WpRequestResult.Success -> { + val serverComment = result.response.data + RsEditResult.Success( + RsEditedComment( + authorName = serverComment.authorName, + authorEmail = serverComment.authorEmail, + authorUrl = serverComment.authorUrl, + contentRaw = serverComment.content.raw + ) + ) + } + is WpRequestResult.WpError -> RsEditResult.Error(result.errorMessage) + else -> RsEditResult.Error(null) + } + } + suspend fun delete(site: SiteModel, commentId: Long): RsResult = write(site) { it.comments().delete(commentId, CommentDeleteParams()) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/EditCancelDialogFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/EditCancelDialogFragment.kt deleted file mode 100644 index 48d1c8057de4..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/EditCancelDialogFragment.kt +++ /dev/null @@ -1,54 +0,0 @@ -package org.wordpress.android.ui.comments.unified - -import android.app.Dialog -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import androidx.lifecycle.ViewModelProvider -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.wordpress.android.R -import org.wordpress.android.WordPress -import javax.inject.Inject - -class EditCancelDialogFragment : DialogFragment() { - @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory - - private var viewModel: UnifiedCommentsEditViewModel? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - (requireActivity().applicationContext as WordPress).component().inject(this) - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - - (parentFragment as? UnifiedCommentsEditFragment)?.let { - viewModel = ViewModelProvider( - it, - viewModelFactory - ).get(UnifiedCommentsEditViewModel::class.java) - } - - builder.apply { - setTitle(R.string.comment_edit_cancel_dialog_title) - setMessage(R.string.comment_edit_cancel_dialog_message) - setPositiveButton(R.string.button_discard) { _, _ -> - viewModel?.onConfirmEditingDiscard() - } - setNegativeButton(R.string.cancel) { _, _ -> - // nothing to be done here. - } - builder.setCancelable(true) - } - return builder.create() - } - - companion object { - const val EDIT_CANCEL_DIALOG_TAG = "edit_cancel_dialog_tag" - - fun newInstance(): EditCancelDialogFragment { - return EditCancelDialogFragment() - } - } -} 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 a59b58dddadb..03a54abb4d71 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 @@ -11,9 +11,7 @@ import android.view.ViewGroup import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.appcompat.app.AppCompatActivity -import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -24,8 +22,6 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope -import com.google.android.material.snackbar.BaseTransientBottomBar.BaseCallback -import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.launch import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe @@ -43,6 +39,7 @@ import org.wordpress.android.ui.comments.unified.UnifiedCommentDetailsViewModel. import org.wordpress.android.ui.comments.unified.compose.CommentDetailsActions import org.wordpress.android.ui.comments.unified.compose.UnifiedCommentDetailsScreen import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.ui.compose.utils.showMessage import org.wordpress.android.ui.notifications.NotificationsListFragment import org.wordpress.android.ui.pages.SnackbarMessageHolder import org.wordpress.android.ui.reader.ReaderActivityLauncher @@ -287,40 +284,11 @@ class UnifiedCommentDetailsFragment : Fragment() { private fun showSnackbar(holder: SnackbarMessageHolder) { val context = context ?: return - val message = uiHelpers.getTextOfUiString(context, holder.message).toString() - val actionLabel = holder.buttonTitle?.let { uiHelpers.getTextOfUiString(context, it).toString() } viewLifecycleOwner.lifecycleScope.launch { - // MANUAL is reported when the view is torn down (e.g. rotation) while the snackbar is - // still showing: cancellation skips the try body, but the finally block still fires - // onDismissAction, matching the legacy Snackbar callback which fired on view detach. - // That matters for the load-error snackbar, whose dismiss action closes the screen. - var dismissEvent = BaseCallback.DISMISS_EVENT_MANUAL - try { - val result = snackbarHostState.showSnackbar( - message = message, - actionLabel = actionLabel, - duration = holder.duration.toSnackbarDuration() - ) - dismissEvent = if (result == SnackbarResult.ActionPerformed) { - holder.buttonAction() - BaseCallback.DISMISS_EVENT_ACTION - } else { - BaseCallback.DISMISS_EVENT_TIMEOUT - } - } finally { - holder.onDismissAction(dismissEvent) - } + snackbarHostState.showMessage(holder, context, uiHelpers) } } - // Compose SnackbarDuration ignores the millisecond-style Snackbar length constants the holders - // carry, so map them explicitly to preserve the legacy LENGTH_LONG display time. - private fun Int.toSnackbarDuration(): SnackbarDuration = when (this) { - Snackbar.LENGTH_SHORT -> SnackbarDuration.Short - Snackbar.LENGTH_INDEFINITE -> SnackbarDuration.Indefinite - else -> SnackbarDuration.Long - } - private fun copyLink(url: String) { if (url.isEmpty()) return val clipboard = requireContext().getSystemService(ClipboardManager::class.java) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentsEditFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentsEditFragment.kt index 6944bda90e1d..31b23e32763d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentsEditFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentsEditFragment.kt @@ -1,238 +1,123 @@ package org.wordpress.android.ui.comments.unified -import android.app.Activity.RESULT_OK import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem +import android.view.LayoutInflater import android.view.View -import androidx.activity.addCallback -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.MenuProvider -import androidx.core.widget.doAfterTextChanged +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity.RESULT_OK +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider -import com.google.android.material.snackbar.Snackbar +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.WordPress -import org.wordpress.android.databinding.UnifiedCommentsEditFragmentBinding import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.ui.ActivityId import org.wordpress.android.ui.ActivityId.COMMENT_EDITOR -import org.wordpress.android.ui.comments.unified.EditCancelDialogFragment.Companion.EDIT_CANCEL_DIALOG_TAG -import org.wordpress.android.ui.comments.unified.UnifiedCommentsEditViewModel.EditCommentActionEvent.CANCEL_EDIT_CONFIRM import org.wordpress.android.ui.comments.unified.UnifiedCommentsEditViewModel.EditCommentActionEvent.CLOSE import org.wordpress.android.ui.comments.unified.UnifiedCommentsEditViewModel.EditCommentActionEvent.DONE -import org.wordpress.android.ui.comments.unified.UnifiedCommentsEditViewModel.FieldType.COMMENT -import org.wordpress.android.ui.comments.unified.UnifiedCommentsEditViewModel.FieldType.USER_EMAIL -import org.wordpress.android.ui.comments.unified.UnifiedCommentsEditViewModel.FieldType.USER_NAME -import org.wordpress.android.ui.comments.unified.UnifiedCommentsEditViewModel.FieldType.WEB_ADDRESS +import org.wordpress.android.ui.comments.unified.UnifiedCommentsEditViewModel.EditCommentUiState +import org.wordpress.android.ui.comments.unified.compose.UnifiedCommentEditScreen +import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.ui.compose.utils.showMessage import org.wordpress.android.ui.pages.SnackbarMessageHolder import org.wordpress.android.ui.utils.UiHelpers -import org.wordpress.android.util.ActivityUtils -import org.wordpress.android.util.SnackbarItem -import org.wordpress.android.util.SnackbarItem.Action -import org.wordpress.android.util.SnackbarItem.Info -import org.wordpress.android.util.SnackbarSequencer import org.wordpress.android.util.extensions.getParcelableCompat import org.wordpress.android.util.extensions.getSerializableCompat import org.wordpress.android.viewmodel.observeEvent import javax.inject.Inject -import android.R as AndroidR -class UnifiedCommentsEditFragment : Fragment(R.layout.unified_comments_edit_fragment), MenuProvider { +class UnifiedCommentsEditFragment : Fragment() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory @Inject lateinit var uiHelpers: UiHelpers - @Inject - lateinit var snackbarSequencer: SnackbarSequencer - private lateinit var viewModel: UnifiedCommentsEditViewModel + private lateinit var site: SiteModel + private lateinit var commentIdentifier: CommentIdentifier + + private val snackbarHostState = SnackbarHostState() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) (requireActivity().application as WordPress).component().inject(this) - viewModel = ViewModelProvider(this, viewModelFactory).get(UnifiedCommentsEditViewModel::class.java) - - ActivityId.trackLastActivity(COMMENT_EDITOR) - } + viewModel = ViewModelProvider(this, viewModelFactory)[UnifiedCommentsEditViewModel::class.java] - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - requireActivity().addMenuProvider(this, viewLifecycleOwner) - - val site = requireNotNull(arguments?.getSerializableCompat(WordPress.SITE)) - val commentIdentifier = requireNotNull( - requireArguments().getParcelableCompat(KEY_COMMENT_IDENTIFIER) - ) - - UnifiedCommentsEditFragmentBinding.bind(view).apply { - setupToolbar() - setupObservers(site, commentIdentifier) - } - } - - private fun UnifiedCommentsEditFragmentBinding.setupToolbar() { - val activity = (requireActivity() as AppCompatActivity) - activity.setSupportActionBar(toolbarMain) - activity.supportActionBar?.let { - it.setHomeButtonEnabled(true) - it.setDisplayHomeAsUpEnabled(true) - it.setHomeAsUpIndicator(R.drawable.ic_cross_white_24dp) - } - activity.onBackPressedDispatcher.addCallback(this@UnifiedCommentsEditFragment) { viewModel.onBackPressed() } - } + site = requireNotNull(arguments?.getSerializableCompat(WordPress.SITE)) + commentIdentifier = requireNotNull(requireArguments().getParcelableCompat(KEY_COMMENT_IDENTIFIER)) - private fun hideKeyboard() { - if (!isAdded || view == null) return - ActivityUtils.hideKeyboardForced(view) + ActivityId.trackLastActivity(COMMENT_EDITOR) } - private fun UnifiedCommentsEditFragmentBinding.setupObservers( - site: SiteModel, - commentIdentifier: CommentIdentifier - ) { - viewModel.uiActionEvent.observeEvent(viewLifecycleOwner) { - when (it) { - CLOSE -> { - requireActivity().finish() - } - DONE -> { - requireActivity().apply { - setResult(RESULT_OK) - finish() - } - } - CANCEL_EDIT_CONFIRM -> { - EditCancelDialogFragment.newInstance().show(childFragmentManager, EDIT_CANCEL_DIALOG_TAG) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + // Stable id so the fragment restores the ComposeView's saved state across rotation. + id = R.id.comment_edit_compose_view + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppThemeM3 { + val uiState by viewModel.uiState.observeAsState(EditCommentUiState(showProgress = true)) + UnifiedCommentEditScreen( + uiState = uiState, + snackbarHostState = snackbarHostState, + onFieldChange = { value, fieldType -> viewModel.onValidateField(value, fieldType) }, + onSaveClick = { viewModel.onActionMenuClicked() }, + onNavigateBack = { viewModel.onBackPressed() }, + onDiscardConfirm = { viewModel.onConfirmEditingDiscard() }, + onDiscardDismiss = { viewModel.onDiscardDialogDismissed() } + ) } } } - - viewModel.onSnackbarMessage.observeEvent(viewLifecycleOwner) { messageHolder -> - showSnackbar(messageHolder) - } - - viewModel.uiState.observe(viewLifecycleOwner) { uiState -> - if (uiState.showProgress) { - loadingView.visibility = View.VISIBLE - scrollView.visibility = View.GONE - uiHelpers.setTextOrHide(progressText, uiState.progressText) - - hideKeyboard() - } else { - loadingView.visibility = View.GONE - scrollView.visibility = View.VISIBLE - } - - if (uiState.shouldInitComment) { - uiState.originalComment.let { - userName.setText(it.userName) - commentEditWebAddress.setText(it.userUrl) - commentEditEmailAddress.setText(it.userEmail) - commentEditComment.setText(it.commentText) - } - } - - if (uiState.shouldInitWatchers) { - initTextWatchers() - } - - uiState.editErrorStrings.let { errors -> - userName.error = errors.userNameError - commentEditWebAddress.error = errors.userUrlError - commentEditEmailAddress.error = errors.userEmailError - commentEditComment.error = errors.commentTextError - } - - with(uiState.inputSettings) { - commentEditComment.isEnabled = enableEditComment - commentEditWebAddress.isEnabled = enableEditUrl - commentEditEmailAddress.isEnabled = enableEditEmail - userName.isEnabled = enableEditName - } - } - - viewModel.start(site, commentIdentifier) } - private fun UnifiedCommentsEditFragmentBinding.showSnackbar(holder: SnackbarMessageHolder) { - snackbarSequencer.enqueue( - SnackbarItem( - Info( - view = coordinator, - textRes = holder.message, - duration = Snackbar.LENGTH_LONG - ), - holder.buttonTitle?.let { - Action( - textRes = holder.buttonTitle, - clickListener = { holder.buttonAction() } - ) - }, - dismissCallback = { _, event -> holder.onDismissAction(event) } - ) - ) - } - - private fun UnifiedCommentsEditFragmentBinding.initTextWatchers() { - userName.doAfterTextChanged { - viewModel.onValidateField(it?.let { StringBuffer(it).toString() } ?: "", USER_NAME) - } - - commentEditWebAddress.doAfterTextChanged { - viewModel.onValidateField(it?.let { StringBuffer(it).toString() } ?: "", WEB_ADDRESS) - } - - commentEditEmailAddress.doAfterTextChanged { - viewModel.onValidateField(it?.let { StringBuffer(it).toString() } ?: "", USER_EMAIL) - } - - commentEditComment.doAfterTextChanged { - viewModel.onValidateField(it?.let { StringBuffer(it).toString() } ?: "", COMMENT) - } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupObservers() + viewModel.start(site, commentIdentifier) } - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.edit_comment_menu, menu) - - menu.findItem(R.id.action_item)?.let { actionMenu -> - actionMenu.setOnMenuItemClickListener { - viewModel.onActionMenuClicked() - true - } - - viewModel.uiState.observe(viewLifecycleOwner) { uiState -> - actionMenu.isEnabled = uiState.canSaveChanges + private fun setupObservers() { + viewModel.uiActionEvent.observeEvent(viewLifecycleOwner) { event -> + when (event) { + CLOSE -> requireActivity().finish() + DONE -> requireActivity().apply { + setResult(RESULT_OK) + finish() + } } } + viewModel.onSnackbarMessage.observeEvent(viewLifecycleOwner) { showSnackbar(it) } } - override fun onMenuItemSelected(menuItem: MenuItem) = when (menuItem.itemId) { - AndroidR.id.home -> { - viewModel.onBackPressed() - true + private fun showSnackbar(holder: SnackbarMessageHolder) { + val context = context ?: return + viewLifecycleOwner.lifecycleScope.launch { + snackbarHostState.showMessage(holder, context, uiHelpers) } - else -> true } companion object { private const val KEY_COMMENT_IDENTIFIER = "key_comment_identifier" fun newInstance(site: SiteModel, commentIdentifier: CommentIdentifier): UnifiedCommentsEditFragment { - val args = Bundle() - - args.putSerializable(WordPress.SITE, site) - args.putParcelable(KEY_COMMENT_IDENTIFIER, commentIdentifier) - - val fragment = UnifiedCommentsEditFragment() - - fragment.arguments = args - - return fragment + return UnifiedCommentsEditFragment().apply { + arguments = Bundle().apply { + putSerializable(WordPress.SITE, site) + putParcelable(KEY_COMMENT_IDENTIFIER, commentIdentifier) + } + } } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentsEditViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentsEditViewModel.kt index 265785677a4f..027f75b93b10 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentsEditViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/UnifiedCommentsEditViewModel.kt @@ -14,11 +14,11 @@ import org.wordpress.android.fluxc.persistence.comments.CommentsDao.CommentEntit import org.wordpress.android.fluxc.store.CommentsStore import org.wordpress.android.models.usecases.LocalCommentCacheUpdateHandler import org.wordpress.android.modules.BG_THREAD +import org.wordpress.android.ui.comments.unified.CommentsRsDataSource.RsEditResult import org.wordpress.android.modules.UI_THREAD import org.wordpress.android.ui.comments.unified.CommentIdentifier.NotificationCommentIdentifier import org.wordpress.android.ui.comments.unified.CommentIdentifier.ReaderCommentIdentifier import org.wordpress.android.ui.comments.unified.CommentIdentifier.SiteCommentIdentifier -import org.wordpress.android.ui.comments.unified.UnifiedCommentsEditViewModel.EditCommentActionEvent.CANCEL_EDIT_CONFIRM import org.wordpress.android.ui.comments.unified.UnifiedCommentsEditViewModel.EditCommentActionEvent.CLOSE import org.wordpress.android.ui.comments.unified.UnifiedCommentsEditViewModel.EditCommentActionEvent.DONE import org.wordpress.android.ui.comments.unified.UnifiedCommentsEditViewModel.FieldType.COMMENT @@ -49,6 +49,7 @@ class UnifiedCommentsEditViewModel @Inject constructor( @Named(UI_THREAD) private val mainDispatcher: CoroutineDispatcher, @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher, private val commentsStore: CommentsStore, + private val commentsRsDataSource: CommentsRsDataSource, private val resourceProvider: ResourceProvider, private val networkUtilsWrapper: NetworkUtilsWrapper, private val localCommentCacheUpdateHandler: LocalCommentCacheUpdateHandler, @@ -66,6 +67,12 @@ class UnifiedCommentsEditViewModel @Inject constructor( val onSnackbarMessage: LiveData> = _onSnackbarMessage private var isStarted = false + + // Written on the main thread in onActionMenuClicked and cleared by the save coroutine; + // @Volatile covers the cross-thread clear. + @Volatile + private var isSaving = false + private lateinit var site: SiteModel private lateinit var commentIdentifier: CommentIdentifier @@ -78,22 +85,15 @@ class UnifiedCommentsEditViewModel @Inject constructor( ) data class EditCommentUiState( - val canSaveChanges: Boolean, - val shouldInitComment: Boolean, - val shouldInitWatchers: Boolean, + val canSaveChanges: Boolean = false, val showProgress: Boolean = false, val progressText: UiString? = null, - val originalComment: CommentEssentials, - val editedComment: CommentEssentials, - val editErrorStrings: EditErrorStrings, - val inputSettings: InputSettings - ) - - data class InputSettings( - val enableEditName: Boolean, - val enableEditUrl: Boolean, - val enableEditEmail: Boolean, - val enableEditComment: Boolean + // Lives in ui state (not a one-shot event) so the dialog survives configuration changes, + // like the DialogFragment it replaced. + val showDiscardDialog: Boolean = false, + val originalComment: CommentEssentials = CommentEssentials(), + val editedComment: CommentEssentials = CommentEssentials(), + val editErrorStrings: EditErrorStrings = EditErrorStrings() ) enum class ProgressState(val show: Boolean, val progressText: UiString?) { @@ -134,17 +134,11 @@ class UnifiedCommentsEditViewModel @Inject constructor( enum class EditCommentActionEvent { CLOSE, - DONE, - CANCEL_EDIT_CONFIRM + DONE } fun start(site: SiteModel, commentIdentifier: CommentIdentifier) { - if (isStarted) { - // If we are here, the fragment view was recreated (like in a configuration change) - // so we reattach the watchers. - _uiState.value = _uiState.value?.copy(shouldInitWatchers = true) - return - } + if (isStarted) return isStarted = true this.site = site @@ -154,17 +148,9 @@ class UnifiedCommentsEditViewModel @Inject constructor( } private suspend fun setLoadingState(state: ProgressState) { - val uiState = _uiState.value ?: EditCommentUiState( - canSaveChanges = false, - shouldInitComment = false, - shouldInitWatchers = false, - showProgress = LOADING.show, - progressText = LOADING.progressText, - originalComment = CommentEssentials(), - editedComment = CommentEssentials(), - editErrorStrings = EditErrorStrings(), - inputSettings = mapInputSettings(CommentEssentials()) - ) + // showProgress/progressText are overwritten by the copy below, so the fallback only needs + // the default field values. + val uiState = _uiState.value ?: EditCommentUiState() withContext(mainDispatcher) { _uiState.value = uiState.copy( @@ -175,15 +161,24 @@ class UnifiedCommentsEditViewModel @Inject constructor( } fun onActionMenuClicked() { + // Set on the main thread before the save launches, so this check-then-set is race-free: + // it closes the window where a second tap lands before the SAVING state disables the + // Save button. + if (isSaving) return if (!networkUtilsWrapper.isNetworkAvailable()) { _onSnackbarMessage.value = Event(SnackbarMessageHolder(UiStringRes(R.string.no_network_message))) return } _uiState.value?.let { uiState -> val editedCommentEssentials = uiState.editedComment + isSaving = true launch(bgDispatcher) { - setLoadingState(SAVING) - updateComment(editedCommentEssentials) + try { + setLoadingState(SAVING) + updateComment(editedCommentEssentials) + } finally { + isSaving = false + } } } } @@ -191,14 +186,19 @@ class UnifiedCommentsEditViewModel @Inject constructor( fun onBackPressed() { _uiState.value?.let { if (it.editedComment.isNotEqualTo(it.originalComment)) { - _uiActionEvent.value = Event(CANCEL_EDIT_CONFIRM) + _uiState.value = it.copy(showDiscardDialog = true) } else { _uiActionEvent.value = Event(CLOSE) } } } + fun onDiscardDialogDismissed() { + _uiState.value?.let { _uiState.value = it.copy(showDiscardDialog = false) } + } + fun onConfirmEditingDiscard() { + _uiState.value?.let { _uiState.value = it.copy(showDiscardDialog = false) } _uiActionEvent.value = Event(CLOSE) } @@ -212,15 +212,10 @@ class UnifiedCommentsEditViewModel @Inject constructor( if (commentEssentials.isValid()) { _uiState.value = EditCommentUiState( - canSaveChanges = false, - shouldInitComment = true, - shouldInitWatchers = true, showProgress = LOADING.show, progressText = LOADING.progressText, originalComment = commentEssentials, - editedComment = commentEssentials, - editErrorStrings = EditErrorStrings(), - inputSettings = mapInputSettings(commentEssentials) + editedComment = commentEssentials ) } else { _onSnackbarMessage.value = Event(SnackbarMessageHolder( @@ -241,8 +236,7 @@ class UnifiedCommentsEditViewModel @Inject constructor( userName = commentEntity.authorName ?: "", commentText = commentEntity.content ?: "", userUrl = commentEntity.authorUrl ?: "", - userEmail = commentEntity.authorEmail ?: "", - isFromRegisteredUser = commentEntity.authorId > 0 + userEmail = commentEntity.authorEmail ?: "" ) } else { CommentEssentials() @@ -282,15 +276,59 @@ class UnifiedCommentsEditViewModel @Inject constructor( comment: CommentEntity, editedCommentEssentials: CommentEssentials ): Boolean { - val updatedComment = comment.copy( - authorUrl = editedCommentEssentials.userUrl, - authorName = editedCommentEssentials.userName, - authorEmail = editedCommentEssentials.userEmail, - content = editedCommentEssentials.commentText - ) + // Prefer wordpress-rs, which can edit comments on both WP.com and self-hosted + // application-password sites (FluxC's updateEditComment can't reach app-password sites). + // Fall back to FluxC for sites rs can't serve — e.g. XML-RPC-only self-hosted comments + // still reachable through the legacy detail/reader launch points. + return if (canUseRs()) { + updateCommentViaRs(comment, editedCommentEssentials) + } else { + val updatedComment = comment.copy( + authorUrl = editedCommentEssentials.userUrl, + authorName = editedCommentEssentials.userName, + authorEmail = editedCommentEssentials.userEmail, + content = editedCommentEssentials.commentText + ) + !commentsStore.updateEditComment(site, updatedComment).isError + } + } - val result = commentsStore.updateEditComment(site, updatedComment) - return !result.isError + private fun canUseRs(): Boolean = site.isUsingWpComRestApi || site.hasApplicationPassword() + + /** + * Saves the edit through wordpress-rs and, on success, mirrors the SERVER's resulting state + * into the FluxC cache so the still-FluxC comment list/notifications reflect the change — + * the same save-then-mirror pattern the unified comment detail uses for moderation. The + * server echo (not the values that were sent) is what's cached so server-side normalisation + * (e.g. KSES content filtering) can't diverge from the cache; the legacy FluxC path also + * cached the server response. + */ + private suspend fun updateCommentViaRs( + comment: CommentEntity, + editedCommentEssentials: CommentEssentials + ): Boolean { + // The endpoint applies author fields to the comment record for any comment (registered + // author or not), same as the legacy FluxC POST and wp-admin's comment editor. + val result = commentsRsDataSource.updateComment( + site = site, + commentId = commentIdentifier.remoteCommentId, + content = editedCommentEssentials.commentText, + author = CommentsRsDataSource.CommentAuthor( + name = editedCommentEssentials.userName, + email = editedCommentEssentials.userEmail, + url = editedCommentEssentials.userUrl + ) + ) + if (result !is RsEditResult.Success) return false + val serverComment = comment.copy( + authorName = result.comment.authorName, + authorEmail = result.comment.authorEmail, + authorUrl = result.comment.authorUrl, + content = result.comment.contentRaw + ) + // Local-only cache write (isError = false persists the entity without a network round-trip). + commentsStore.updateComment(isError = false, commentId = serverComment.id, comment = serverComment) + return true } private suspend fun updateNotificationEntity() { @@ -366,21 +404,12 @@ class UnifiedCommentsEditViewModel @Inject constructor( _uiState.value = it.copy( canSaveChanges = editedComment.isNotEqualTo(it.originalComment) && !errors.hasError(), - shouldInitComment = false, - shouldInitWatchers = false, editedComment = editedComment, editErrorStrings = errors ) } } - private fun mapInputSettings(commentEssentials: CommentEssentials) = InputSettings( - enableEditName = !commentEssentials.isFromRegisteredUser, - enableEditUrl = !commentEssentials.isFromRegisteredUser, - enableEditEmail = !commentEssentials.isFromRegisteredUser, - enableEditComment = true - ) - private fun EditErrorStrings.hasError(): Boolean { return listOf( this.commentTextError, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/compose/UnifiedCommentEditScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/compose/UnifiedCommentEditScreen.kt new file mode 100644 index 000000000000..7325c1204916 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/compose/UnifiedCommentEditScreen.kt @@ -0,0 +1,205 @@ +package org.wordpress.android.ui.comments.unified.compose + +import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import org.wordpress.android.ui.comments.unified.UnifiedCommentsEditViewModel.EditCommentUiState +import org.wordpress.android.ui.comments.unified.UnifiedCommentsEditViewModel.FieldType +import org.wordpress.android.ui.comments.unified.UnifiedCommentsEditViewModel.FieldType.COMMENT +import org.wordpress.android.ui.comments.unified.UnifiedCommentsEditViewModel.FieldType.USER_EMAIL +import org.wordpress.android.ui.comments.unified.UnifiedCommentsEditViewModel.FieldType.USER_NAME +import org.wordpress.android.ui.comments.unified.UnifiedCommentsEditViewModel.FieldType.WEB_ADDRESS +import org.wordpress.android.ui.compose.utils.uiStringText + +/** + * Compose editor for a comment's author identity and body, replacing the legacy XML + * unified_comments_edit_fragment. State and validation stay in UnifiedCommentsEditViewModel; this + * screen is stateless and drives every field from [uiState]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UnifiedCommentEditScreen( + uiState: EditCommentUiState, + snackbarHostState: SnackbarHostState, + onFieldChange: (String, FieldType) -> Unit, + onSaveClick: () -> Unit, + onNavigateBack: () -> Unit, + onDiscardConfirm: () -> Unit, + onDiscardDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + BackHandler(onBack = onNavigateBack) + // Hoisted above the progress swap below so the scroll position survives a failed save. + val scrollState = rememberScrollState() + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.edit_comment)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + painter = painterResource(R.drawable.ic_cross_white_24dp), + contentDescription = stringResource(R.string.close_dialog_button_desc) + ) + } + }, + actions = { + // Disable while saving/loading so a second tap can't fire a duplicate save. + TextButton( + onClick = onSaveClick, + enabled = uiState.canSaveChanges && !uiState.showProgress + ) { + Text(stringResource(R.string.comment_edit_menu_done)) + } + } + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + modifier = modifier + ) { contentPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + ) { + if (uiState.showProgress) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator() + uiState.progressText?.let { + Text( + text = uiStringText(it), + modifier = Modifier.padding(top = 16.dp) + ) + } + } + } else { + EditFields(uiState, onFieldChange, scrollState) + } + } + } + + if (uiState.showDiscardDialog) { + AlertDialog( + onDismissRequest = onDiscardDismiss, + title = { Text(stringResource(R.string.comment_edit_cancel_dialog_title)) }, + text = { Text(stringResource(R.string.comment_edit_cancel_dialog_message)) }, + confirmButton = { + TextButton(onClick = onDiscardConfirm) { + Text(stringResource(R.string.button_discard)) + } + }, + dismissButton = { + TextButton(onClick = onDiscardDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) + } +} + +@Composable +private fun EditFields( + uiState: EditCommentUiState, + onFieldChange: (String, FieldType) -> Unit, + scrollState: ScrollState +) { + val edited = uiState.editedComment + val errors = uiState.editErrorStrings + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Autocorrect stays off for the author-identity fields — the legacy XML only opted the + // comment body into textAutoCorrect, and autocorrecting proper nouns corrupts names. + EditField( + value = edited.userName, + labelRes = R.string.comment_edit_user_name, + error = errors.userNameError, + keyboardOptions = KeyboardOptions(autoCorrectEnabled = false), + onValueChange = { onFieldChange(it, USER_NAME) } + ) + EditField( + value = edited.userUrl, + labelRes = R.string.comment_edit_web_address, + error = errors.userUrlError, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, autoCorrectEnabled = false), + onValueChange = { onFieldChange(it, WEB_ADDRESS) } + ) + EditField( + value = edited.userEmail, + labelRes = R.string.comment_edit_email_address, + error = errors.userEmailError, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, autoCorrectEnabled = false), + onValueChange = { onFieldChange(it, USER_EMAIL) } + ) + EditField( + value = edited.commentText, + labelRes = R.string.comment_edit_comment, + error = errors.commentTextError, + singleLine = false, + // Sentence capitalization + autocorrect, matching the legacy field and the reply box. + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + onValueChange = { onFieldChange(it, COMMENT) } + ) + } +} + +@Composable +private fun EditField( + value: String, + @StringRes labelRes: Int, + error: String?, + keyboardOptions: KeyboardOptions, + onValueChange: (String) -> Unit, + singleLine: Boolean = true +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + isError = error != null, + singleLine = singleLine, + minLines = if (singleLine) 1 else COMMENT_FIELD_MIN_LINES, + label = { Text(stringResource(labelRes)) }, + supportingText = error?.let { { Text(it) } }, + keyboardOptions = keyboardOptions, + modifier = Modifier.fillMaxWidth() + ) +} + +private const val COMMENT_FIELD_MIN_LINES = 5 diff --git a/WordPress/src/main/java/org/wordpress/android/ui/compose/utils/SnackbarHostStateExtensions.kt b/WordPress/src/main/java/org/wordpress/android/ui/compose/utils/SnackbarHostStateExtensions.kt new file mode 100644 index 000000000000..60a8df1cd4e2 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/compose/utils/SnackbarHostStateExtensions.kt @@ -0,0 +1,50 @@ +package org.wordpress.android.ui.compose.utils + +import android.content.Context +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import com.google.android.material.snackbar.BaseTransientBottomBar.BaseCallback +import com.google.android.material.snackbar.Snackbar +import org.wordpress.android.ui.pages.SnackbarMessageHolder +import org.wordpress.android.ui.utils.UiHelpers + +/** + * Shows a [SnackbarMessageHolder] on a Compose [SnackbarHostState], bridging the View-world Snackbar + * length constants and dismiss-event codes the holder carries. onDismissAction fires from a finally + * so it still runs if the coroutine is cancelled (e.g. the view is torn down while the snackbar is + * showing) — the load-error holder relies on this to close the screen. + * + * Call from the caller's own coroutine scope, e.g. + * `viewLifecycleOwner.lifecycleScope.launch { snackbarHostState.showMessage(holder, context, uiHelpers) }`. + */ +suspend fun SnackbarHostState.showMessage( + holder: SnackbarMessageHolder, + context: Context, + uiHelpers: UiHelpers +) { + val message = uiHelpers.getTextOfUiString(context, holder.message).toString() + val actionLabel = holder.buttonTitle?.let { uiHelpers.getTextOfUiString(context, it).toString() } + var dismissEvent = BaseCallback.DISMISS_EVENT_MANUAL + try { + val result = showSnackbar( + message = message, + actionLabel = actionLabel, + duration = holder.duration.toSnackbarDuration() + ) + dismissEvent = if (result == SnackbarResult.ActionPerformed) { + holder.buttonAction() + BaseCallback.DISMISS_EVENT_ACTION + } else { + BaseCallback.DISMISS_EVENT_TIMEOUT + } + } finally { + holder.onDismissAction(dismissEvent) + } +} + +private fun Int.toSnackbarDuration(): SnackbarDuration = when (this) { + Snackbar.LENGTH_SHORT -> SnackbarDuration.Short + Snackbar.LENGTH_INDEFINITE -> SnackbarDuration.Indefinite + else -> SnackbarDuration.Long +} diff --git a/WordPress/src/main/res/layout/unified_comments_edit_fragment.xml b/WordPress/src/main/res/layout/unified_comments_edit_fragment.xml deleted file mode 100644 index 704e3c613a90..000000000000 --- a/WordPress/src/main/res/layout/unified_comments_edit_fragment.xml +++ /dev/null @@ -1,141 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/src/main/res/menu/edit_comment_menu.xml b/WordPress/src/main/res/menu/edit_comment_menu.xml deleted file mode 100644 index 0178e92d6e1f..000000000000 --- a/WordPress/src/main/res/menu/edit_comment_menu.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - diff --git a/WordPress/src/main/res/values/ids.xml b/WordPress/src/main/res/values/ids.xml index 4bc6e3180e14..8de6e6732144 100644 --- a/WordPress/src/main/res/values/ids.xml +++ b/WordPress/src/main/res/values/ids.xml @@ -24,4 +24,5 @@ + diff --git a/WordPress/src/main/res/values/styles.xml b/WordPress/src/main/res/values/styles.xml index 3621a4a7f811..681d6146ce5f 100644 --- a/WordPress/src/main/res/values/styles.xml +++ b/WordPress/src/main/res/values/styles.xml @@ -420,10 +420,6 @@ ?attr/wpColorOnSurfaceMedium - -