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 71710e84eb3c..a59b58dddadb 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 @@ -5,90 +5,69 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Intent import android.os.Bundle -import android.text.Spannable -import android.text.SpannableString -import android.text.style.ForegroundColorSpan -import android.view.MenuItem +import android.view.LayoutInflater import android.view.View -import android.widget.ImageView -import android.widget.TextView +import android.view.ViewGroup import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.widget.PopupMenu -import androidx.core.content.ContextCompat -import androidx.core.content.res.ResourcesCompat -import androidx.core.widget.doAfterTextChanged +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 +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider -import com.google.android.material.dialog.MaterialAlertDialogBuilder +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 +import org.greenrobot.eventbus.ThreadMode import org.wordpress.android.R -import com.google.android.material.R as MaterialR import org.wordpress.android.WordPress -import org.wordpress.android.databinding.ReaderIncludeCommentBoxBinding -import org.wordpress.android.databinding.UnifiedCommentDetailsFragmentBinding -import org.wordpress.android.fluxc.model.CommentStatus.APPROVED -import org.wordpress.android.fluxc.model.CommentStatus.SPAM -import org.wordpress.android.fluxc.model.CommentStatus.TRASH -import org.wordpress.android.fluxc.model.CommentStatus.UNAPPROVED +import org.wordpress.android.datasets.UserSuggestionTable 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.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.notifications.NotificationsListFragment import org.wordpress.android.ui.pages.SnackbarMessageHolder import org.wordpress.android.ui.reader.ReaderActivityLauncher +import org.wordpress.android.ui.suggestion.Suggestion +import org.wordpress.android.ui.suggestion.service.SuggestionEvents.SuggestionNameListUpdated import org.wordpress.android.ui.suggestion.util.SuggestionServiceConnectionManager -import org.wordpress.android.ui.suggestion.util.SuggestionUtils import org.wordpress.android.ui.utils.UiHelpers import org.wordpress.android.ui.utils.UiString.UiStringRes import org.wordpress.android.util.ActivityUtils import org.wordpress.android.util.AppLog -import org.wordpress.android.util.ColorUtils import org.wordpress.android.util.SiteUtils -import org.wordpress.android.util.SnackbarItem -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 -import org.wordpress.android.util.image.ImageManager -import org.wordpress.android.util.image.ImageType import org.wordpress.android.viewmodel.observeEvent +import org.wordpress.persistentedittext.PersistentEditTextDatabase import javax.inject.Inject -class UnifiedCommentDetailsFragment : - Fragment(R.layout.unified_comment_details_fragment), - CollapseFullScreenDialogFragment.OnConfirmListener, - CollapseFullScreenDialogFragment.OnCollapseListener { +class UnifiedCommentDetailsFragment : Fragment() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory @Inject lateinit var uiHelpers: UiHelpers - @Inject - lateinit var imageManager: ImageManager - - @Inject - lateinit var snackbarSequencer: SnackbarSequencer - - @Inject - lateinit var commentListUiUtils: CommentListUiUtils - private lateinit var viewModel: UnifiedCommentDetailsViewModel - private var binding: UnifiedCommentDetailsFragmentBinding? = null private lateinit var site: SiteModel private var remoteCommentId: Long = 0 @@ -99,13 +78,19 @@ class UnifiedCommentDetailsFragment : // 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 + // Compose state owned by the fragment so the ViewModel observers can drive it: the reply + // draft (cleared on send), the @-mention suggestions and the snackbar queue. + private val replyText = mutableStateOf(TextFieldValue("")) + private val suggestions = mutableStateOf>(emptyList()) + private val snackbarHostState = SnackbarHostState() - private val mediumOpacity by lazy { - ResourcesCompat.getFloat(resources, MaterialR.dimen.material_emphasis_medium) - } + // Persists the reply draft across process death, replacing the legacy reply box's + // PersistentEditTextHelper (same library, keyed explicitly instead of by view path). + private val draftDatabase by lazy { PersistentEditTextDatabase(requireContext()) } + + // Key drafts by the local site id: siteId (the WP.com blog id) is 0 for all self-hosted + // application-password sites, which would collide drafts across sites. + private val draftKey get() = "unified_comment_details_${site.id}-$remoteCommentId" private val editCommentLauncher: ActivityResultLauncher = registerForActivityResult(StartActivityForResult()) { result -> @@ -118,124 +103,76 @@ class UnifiedCommentDetailsFragment : super.onCreate(savedInstanceState) (requireActivity().application as WordPress).component().inject(this) viewModel = ViewModelProvider(this, viewModelFactory)[UnifiedCommentDetailsViewModel::class.java] - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - site = requireNotNull(arguments?.getSerializableCompat(WordPress.SITE)) remoteCommentId = requireArguments().getLong(KEY_REMOTE_COMMENT_ID) noteId = arguments?.getString(KEY_NOTE_ID) - - UnifiedCommentDetailsFragmentBinding.bind(view).apply { - binding = this - setupClickListeners() - 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, noteId) - } - - private fun UnifiedCommentDetailsFragmentBinding.setupClickListeners() { - layoutButtons.btnModerate.setOnClickListener { viewModel.onApproveClicked() } - layoutButtons.btnSpam.setOnClickListener { viewModel.onSpamClicked() } - layoutButtons.btnLike.setOnClickListener { viewModel.onLikeClicked() } - layoutButtons.btnMore.setOnClickListener { showMoreMenu(it) } - textPostTitle.setOnClickListener { viewModel.onPostTitleClicked() } - // Spam is hidden by default in the shared comment_action_footer layout; the unified detail - // always offers it, so show it up front rather than only once the comment has loaded. - layoutButtons.btnSpam.visibility = View.VISIBLE - // Liking stays on FluxC, which only supports WP.com-accessed sites — hide the button on - // self-hosted application-password sites like the legacy comment detail does. - layoutButtons.btnLike.visibility = - if (SiteUtils.isAccessedViaWPComRest(site)) View.VISIBLE else View.GONE } - private fun ReaderIncludeCommentBoxBinding.setupReplyBox(isFreshView: Boolean) { - layoutContainer.visibility = View.VISIBLE - editComment.initializeWithPrefix('@') - editComment.doAfterTextChanged { btnSubmitReply.isEnabled = !it.isNullOrBlank() } - btnSubmitReply.setOnClickListener { viewModel.onReplyClicked(editComment.text.toString()) } - buttonExpand.setOnClickListener { showFullScreenReply() } - // Key drafts by the local site id: siteId (the WP.com blog id) is 0 for all self-hosted - // 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) - } + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { // 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() + val focusReplyField = + savedInstanceState == null && arguments?.getBoolean(KEY_FOCUS_REPLY_FIELD) == true + return ComposeView(requireContext()).apply { + // A stable id (not View.generateViewId()) lets the fragment restore the ComposeView's + // saved state across rotation, so rememberSaveable dialog flags survive config changes. + id = R.id.comment_detail_compose_view + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppThemeM3 { + val uiState by viewModel.uiState.observeAsState(CommentDetailsUiState()) + val replyTextValue by replyText + val suggestionList by suggestions + UnifiedCommentDetailsScreen( + uiState = uiState, + replyText = replyTextValue, + onReplyTextChange = { replyText.value = it }, + suggestions = suggestionList, + // Liking stays on FluxC, which only supports WP.com-accessed sites — hide + // the button on self-hosted application-password sites like the legacy + // comment detail does. + showLikeButton = SiteUtils.isAccessedViaWPComRest(site), + focusReplyFieldOnLaunch = focusReplyField, + snackbarHostState = snackbarHostState, + actions = actions + ) + } + } } } - private fun ReaderIncludeCommentBoxBinding.setupSuggestions() { - if (!SiteUtils.isAccessedViaWPComRest(site)) return - val connectionManager = SuggestionServiceConnectionManager(requireActivity(), site.siteId) - suggestionServiceConnectionManager = connectionManager - editComment.setAdapter(SuggestionUtils.setupUserSuggestions(site, requireActivity(), connectionManager)) - } - - private fun showFullScreenReply() { - val box = binding?.layoutCommentBox ?: return - val bundle = CommentFullScreenDialogFragment.newBundle( - box.editComment.text.toString(), - box.editComment.selectionStart, - box.editComment.selectionEnd, - site.siteId - ) - CollapseFullScreenDialogFragment.Builder(requireContext()) - .setTitle(R.string.comment) - .setOnCollapseListener(this) - .setOnConfirmListener(this) - .setContent(CommentFullScreenDialogFragment::class.java, bundle) - .setAction(R.string.send) - .setHideActivityBar(true) - .build() - .show(requireActivity().supportFragmentManager, fullScreenDialogTag()) - } - - override fun onConfirm(result: Bundle?) { - val box = binding?.layoutCommentBox ?: return - val reply = result ?: return - box.editComment.setText(reply.getString(CommentFullScreenDialogFragment.RESULT_REPLY)) - viewModel.onReplyClicked(box.editComment.text.toString()) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupSuggestions() + loadReplyDraft(isFreshView = savedInstanceState == null) + 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(view.id) + viewModel.start(site, remoteCommentId, noteId) } - override fun onCollapse(result: Bundle?) { - val box = binding?.layoutCommentBox ?: return - val reply = result ?: return - box.editComment.setText(reply.getString(CommentFullScreenDialogFragment.RESULT_REPLY)) - box.editComment.setSelection( - reply.getInt(CommentFullScreenDialogFragment.RESULT_SELECTION_START), - reply.getInt(CommentFullScreenDialogFragment.RESULT_SELECTION_END) + // A single instance for the fragment's lifetime, so recompositions see a stable parameter + private val actions by lazy { + CommentDetailsActions( + onModerateClick = { viewModel.onApproveClicked() }, + onSpamClick = { viewModel.onSpamClicked() }, + onLikeClick = { viewModel.onLikeClicked() }, + onEditClick = { viewModel.onEditClicked() }, + onTrashClick = { viewModel.onTrashClicked() }, + onDeletePermanentlyClick = { viewModel.onDeletePermanentlyClicked() }, + onCopyLinkClick = { copyLink(viewModel.uiState.value?.commentUrl.orEmpty()) }, + onShareLinkClick = { shareLink(viewModel.uiState.value?.commentUrl.orEmpty()) }, + onPostTitleClick = { viewModel.onPostTitleClicked() }, + onSendReply = { viewModel.onReplyClicked(it) } ) - box.editComment.requestFocus() - } - - override fun onResume() { - super.onResume() - // Reattach listeners to a collapsible reply dialog that may have survived recreation - val fragment = requireActivity().supportFragmentManager - .findFragmentByTag(fullScreenDialogTag()) as? CollapseFullScreenDialogFragment - if (fragment != null && fragment.isAdded) { - fragment.setOnCollapseListener(this) - fragment.setOnConfirmListener(this) - } } - private fun UnifiedCommentDetailsFragmentBinding.setupObservers() { - viewModel.uiState.observe(viewLifecycleOwner) { renderUiState(it) } - + private fun setupObservers() { viewModel.uiActionEvent.observeEvent(viewLifecycleOwner) { event -> when (event) { is Close -> requireActivity().finish() @@ -274,195 +211,114 @@ class UnifiedCommentDetailsFragment : } } - private fun clearReplyInput() { - binding?.layoutCommentBox?.editComment?.let { edit -> - edit.setText("") - edit.autoSaveTextHelper.clearSavedText(edit) - } - view?.let { ActivityUtils.hideKeyboardForced(it) } + /** + * Binds the user-suggestion service so `@`-mention suggestions are downloaded, and seeds the + * suggestion state from the local table — WP.com-accessed sites only, like the legacy detail. + * [onSuggestionsUpdated] refreshes the state when the service reports new data. + */ + private fun setupSuggestions() { + if (!SiteUtils.isAccessedViaWPComRest(site)) return + val connectionManager = suggestionServiceConnectionManager + ?: SuggestionServiceConnectionManager(requireActivity(), site.siteId).also { + suggestionServiceConnectionManager = it + } + connectionManager.bindToService() + loadSuggestions() } - private fun UnifiedCommentDetailsFragmentBinding.renderUiState(uiState: CommentDetailsUiState) { - progressBar.visibility = if (uiState.showProgress) View.VISIBLE else View.GONE - // INVISIBLE (not GONE) so the scroll view keeps its weighted space while the comment loads, - // which keeps the action buttons pinned to the bottom instead of floating to the top. - scrollView.visibility = if (uiState.contentVisible) View.VISIBLE else View.INVISIBLE - if (!uiState.contentVisible) return - - textAuthorName.text = uiState.authorName - textDate.text = uiState.datePublished - renderCommentContent(uiState.commentText) - uiHelpers.setTextOrHide(textPostTitle, uiState.postTitle) - - if (uiState.authorAvatarUrl.isNotEmpty()) { - imageManager.loadIntoCircle(imageAvatar, ImageType.AVATAR_WITHOUT_BACKGROUND, uiState.authorAvatarUrl) - } - - textStatus.setText( - when (uiState.status) { - APPROVED -> R.string.comment_status_approved - UNAPPROVED -> R.string.comment_status_unapproved - SPAM -> R.string.comment_status_spam - TRASH -> R.string.comment_status_trash - else -> R.string.comment_status_all - } + private fun loadSuggestions() { + suggestions.value = Suggestion.fromUserSuggestions( + UserSuggestionTable.getSuggestionsForSite(site.siteId) ?: emptyList() ) - val statusIsDestructive = uiState.status == TRASH || uiState.status == SPAM - textStatus.setTextColor( - requireContext().getColorFromAttribute( - if (statusIsDestructive) androidx.appcompat.R.attr.colorError else R.attr.wpColorOnSurfaceMedium - ) - ) - - renderActionButtons(uiState) - renderReplyBox(uiState) } - /** - * Renders the comment body with the shared comment HTML renderer (inline images, emoticons, - * whitespace trimming) and tappable links, matching the legacy comment detail. Posted so the - * view is measured, since the renderer sizes inline images from the view width. - */ - private fun UnifiedCommentDetailsFragmentBinding.renderCommentContent(commentHtml: String) { - if (commentHtml == renderedCommentHtml) return - renderedCommentHtml = commentHtml - textCommentContent.movementMethod = WPLinkMovementMethod.getInstance() - textCommentContent.post { - commentListUiUtils.displayHtmlComment( - commentHtml, - textCommentContent, - textCommentContent.width, - textCommentContent.lineHeight - ) + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + fun onSuggestionsUpdated(event: SuggestionNameListUpdated) { + if (event.mRemoteBlogId != 0L && event.mRemoteBlogId == site.siteId) { + loadSuggestions() } } - private fun UnifiedCommentDetailsFragmentBinding.renderActionButtons(uiState: CommentDetailsUiState) { - with(layoutButtons) { - when (uiState.status) { - APPROVED -> styleActionButton( - btnModerateIcon, btnModerateText, - R.drawable.ic_checkmark_white_24dp, R.string.comment_status_approved, isOn = true - ) - TRASH -> styleActionButton( - btnModerateIcon, btnModerateText, - R.drawable.ic_undo_white_24dp, R.string.mnu_comment_untrash, isOn = false - ) - else -> styleActionButton( - btnModerateIcon, btnModerateText, - R.drawable.ic_checkmark_white_24dp, R.string.mnu_comment_approve, isOn = false - ) - } + override fun onStart() { + super.onStart() + EventBus.getDefault().register(this) + } - btnSpamText.setText( - if (uiState.status == SPAM) R.string.mnu_comment_unspam else R.string.mnu_comment_spam - ) + override fun onStop() { + EventBus.getDefault().unregister(this) + super.onStop() + } - styleActionButton( - btnLikeIcon, btnLikeText, - if (uiState.isLiked) R.drawable.ic_star_white_24dp else R.drawable.ic_star_outline_white_24dp, - if (uiState.isLiked) R.string.mnu_comment_liked else R.string.like, - isOn = uiState.isLiked - ) - } + override fun onPause() { + super.onPause() + saveReplyDraft() } /** - * Styles a footer action button (icon + label) for its on/off state, matching the legacy - * comment detail: accent colour at full opacity when on, [MaterialR.attr.colorOnSurface] at - * medium opacity when off. + * Restores the reply draft (saved in [onPause]) into the reply field. Prefill from the + * notification's inline-reply text applies only on a fresh view and never clobbers a draft. */ - private fun styleActionButton( - icon: ImageView, - text: TextView, - @DrawableRes iconRes: Int, - @StringRes textRes: Int, - isOn: Boolean - ) { - val colorRes = requireContext().getColorResIdFromAttribute( - if (isOn) MaterialR.attr.colorSecondary else MaterialR.attr.colorOnSurface - ) - ColorUtils.setImageResourceWithTint(icon, iconRes, colorRes) - text.setText(textRes) - text.setTextColor(ContextCompat.getColor(requireContext(), colorRes)) - val alpha = if (isOn) 1f else mediumOpacity - icon.alpha = alpha - text.alpha = alpha + private fun loadReplyDraft(isFreshView: Boolean) { + val draft = draftDatabase.get(draftKey, "") + val prefill = arguments?.getString(KEY_PREFILL_REPLY_TEXT) + val initial = when { + draft.isNotEmpty() -> draft + isFreshView && !prefill.isNullOrEmpty() -> prefill + else -> return + } + replyText.value = TextFieldValue(initial, selection = TextRange(initial.length)) } - private fun UnifiedCommentDetailsFragmentBinding.renderReplyBox(uiState: CommentDetailsUiState) { - with(layoutCommentBox) { - editComment.hint = if (uiState.authorName.isNotBlank()) { - getString(R.string.comment_reply_to_user, uiState.authorName) - } else { - getString(R.string.reader_hint_comment_on_post) - } - editComment.isEnabled = !uiState.isReplyInProgress - progressSubmitComment.visibility = if (uiState.isReplyInProgress) View.VISIBLE else View.GONE - btnSubmitReply.visibility = if (uiState.isReplyInProgress) View.GONE else View.VISIBLE + private fun saveReplyDraft() { + val text = replyText.value.text + if (text.isBlank()) { + draftDatabase.remove(draftKey) + } else { + draftDatabase.put(draftKey, text) } } - private fun UnifiedCommentDetailsFragmentBinding.showSnackbar(holder: SnackbarMessageHolder) { - snackbarSequencer.enqueue( - SnackbarItem( - Info( - view = coordinator, - textRes = holder.message, - duration = Snackbar.LENGTH_LONG - ), - dismissCallback = { _, event -> holder.onDismissAction(event) } - ) - ) + private fun clearReplyInput() { + replyText.value = TextFieldValue("") + draftDatabase.remove(draftKey) + view?.let { ActivityUtils.hideKeyboardForced(it) } } - private fun showMoreMenu(anchor: View) { - val state = viewModel.uiState.value ?: return - val errorColor = requireContext().getColorFromAttribute(androidx.appcompat.R.attr.colorError) - PopupMenu(requireContext(), anchor).apply { - menuInflater.inflate(R.menu.unified_comment_details_more, menu) - menu.findItem(R.id.menu_trash).apply { - if (state.status == TRASH) { - setTitle(R.string.mnu_comment_untrash) + 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 { - setTitle(R.string.mnu_comment_trash) - setTitleColor(errorColor) - } - } - menu.findItem(R.id.menu_copy_link).isVisible = state.commentUrl.isNotEmpty() - menu.findItem(R.id.menu_share_link).isVisible = state.commentUrl.isNotEmpty() - menu.findItem(R.id.menu_delete_permanently).apply { - isVisible = state.status == TRASH || state.status == SPAM - setTitleColor(errorColor) - } - setOnMenuItemClickListener { item -> - when (item.itemId) { - R.id.menu_edit -> viewModel.onEditClicked() - // Trashing is committed server-side immediately (no undo affordance like the - // legacy list flow), so confirm it first; restoring needs no confirmation. - R.id.menu_trash -> if (state.status == TRASH) { - viewModel.onTrashClicked() - } else { - confirmTrash() - } - R.id.menu_copy_link -> copyLink(state.commentUrl) - R.id.menu_share_link -> shareLink(state.commentUrl) - R.id.menu_delete_permanently -> confirmDeletePermanently() - else -> return@setOnMenuItemClickListener false + BaseCallback.DISMISS_EVENT_TIMEOUT } - true + } finally { + holder.onDismissAction(dismissEvent) } - show() } } - /** Tints a popup menu item's title, since PopupMenu items can't be coloured from XML. */ - private fun MenuItem.setTitleColor(color: Int) { - title = title?.let { - SpannableString(it).apply { - setSpan(ForegroundColorSpan(color), 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - } - } + // 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) { @@ -474,7 +330,7 @@ class UnifiedCommentDetailsFragment : } else { R.string.error_copy_to_clipboard } - binding?.showSnackbar(SnackbarMessageHolder(UiStringRes(message))) + showSnackbar(SnackbarMessageHolder(UiStringRes(message))) } private fun shareLink(url: String) { @@ -491,31 +347,6 @@ class UnifiedCommentDetailsFragment : } } - private fun confirmTrash() { - MaterialAlertDialogBuilder(requireContext()) - .setMessage(R.string.dlg_confirm_trash_comments) - .setPositiveButton(R.string.dlg_confirm_action_trash) { _, _ -> viewModel.onTrashClicked() } - .setNegativeButton(R.string.cancel, null) - .show() - } - - private fun confirmDeletePermanently() { - MaterialAlertDialogBuilder(requireContext()) - .setMessage(R.string.dlg_sure_to_delete_comment) - .setPositiveButton(R.string.delete) { _, _ -> viewModel.onDeletePermanentlyClicked() } - .setNegativeButton(R.string.cancel, null) - .show() - } - - private fun fullScreenDialogTag() = "${CollapseFullScreenDialogFragment.TAG}_${site.id}_$remoteCommentId" - - override fun onDestroyView() { - super.onDestroyView() - binding = null - // Force a re-render into the recreated TextView if the view is rebuilt (e.g. rotation) - renderedCommentHtml = null - } - override fun onDestroy() { suggestionServiceConnectionManager?.unbindFromService() super.onDestroy() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/compose/CommentActionFooter.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/compose/CommentActionFooter.kt new file mode 100644 index 000000000000..e4a87bf59ce9 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/compose/CommentActionFooter.kt @@ -0,0 +1,235 @@ +package org.wordpress.android.ui.comments.unified.compose + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.CommentStatus +import org.wordpress.android.fluxc.model.CommentStatus.APPROVED +import org.wordpress.android.fluxc.model.CommentStatus.SPAM +import org.wordpress.android.fluxc.model.CommentStatus.TRASH +import org.wordpress.android.ui.compose.theme.AppThemeM3 + +/** + * The row of comment actions pinned above the reply box: moderate (approve/unapprove/untrash), + * spam, like and a "more" overflow menu (edit, trash, copy/share link, delete permanently). + * Mirrors the legacy comment_action_footer layout: equal-width icon+label buttons, accent colour + * at full opacity when a toggle is on, on-surface at medium opacity when off. + */ +@Composable +@Suppress("LongParameterList") +fun CommentActionFooter( + status: CommentStatus, + isLiked: Boolean, + showLikeButton: Boolean, + showCommentUrlActions: Boolean, + onModerateClick: () -> Unit, + onSpamClick: () -> Unit, + onLikeClick: () -> Unit, + onEditClick: () -> Unit, + onTrashClick: () -> Unit, + onCopyLinkClick: () -> Unit, + onShareLinkClick: () -> Unit, + onDeletePermanentlyClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row(modifier = modifier.fillMaxWidth()) { + val (moderateIconRes, moderateLabelRes, moderateIsOn) = when (status) { + APPROVED -> Triple(R.drawable.ic_checkmark_white_24dp, R.string.comment_status_approved, true) + TRASH -> Triple(R.drawable.ic_undo_white_24dp, R.string.mnu_comment_untrash, false) + else -> Triple(R.drawable.ic_checkmark_white_24dp, R.string.mnu_comment_approve, false) + } + ActionButton( + iconRes = moderateIconRes, + labelRes = moderateLabelRes, + isOn = moderateIsOn, + onClick = onModerateClick, + modifier = Modifier.weight(1f) + ) + + ActionButton( + iconRes = R.drawable.ic_spam_white_24dp, + labelRes = if (status == SPAM) R.string.mnu_comment_unspam else R.string.mnu_comment_spam, + isOn = false, + onClick = onSpamClick, + modifier = Modifier.weight(1f) + ) + + if (showLikeButton) { + ActionButton( + iconRes = if (isLiked) R.drawable.ic_star_white_24dp else R.drawable.ic_star_outline_white_24dp, + labelRes = if (isLiked) R.string.mnu_comment_liked else R.string.like, + isOn = isLiked, + onClick = onLikeClick, + modifier = Modifier.weight(1f) + ) + } + + MoreActionButton( + status = status, + showCommentUrlActions = showCommentUrlActions, + onEditClick = onEditClick, + onTrashClick = onTrashClick, + onCopyLinkClick = onCopyLinkClick, + onShareLinkClick = onShareLinkClick, + onDeletePermanentlyClick = onDeletePermanentlyClick, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +@Suppress("LongParameterList") +private fun MoreActionButton( + status: CommentStatus, + showCommentUrlActions: Boolean, + onEditClick: () -> Unit, + onTrashClick: () -> Unit, + onCopyLinkClick: () -> Unit, + onShareLinkClick: () -> Unit, + onDeletePermanentlyClick: () -> Unit, + modifier: Modifier = Modifier +) { + var isMenuExpanded by remember { mutableStateOf(false) } + Box(modifier = modifier) { + ActionButton( + iconRes = R.drawable.ic_more_horiz_white_24dp, + labelRes = R.string.more, + isOn = false, + onClick = { isMenuExpanded = true }, + // Fill the Box (which carries this button's share of the row) so the icon centres in + // its slot like the sibling buttons, instead of hugging the slot's start edge + modifier = Modifier.fillMaxWidth() + ) + DropdownMenu( + expanded = isMenuExpanded, + onDismissRequest = { isMenuExpanded = false } + ) { + val errorColor = MaterialTheme.colorScheme.error + MoreMenuItem(R.string.edit) { + isMenuExpanded = false + onEditClick() + } + if (status == TRASH) { + MoreMenuItem(R.string.mnu_comment_untrash) { + isMenuExpanded = false + onTrashClick() + } + } else { + MoreMenuItem(R.string.mnu_comment_trash, color = errorColor) { + isMenuExpanded = false + onTrashClick() + } + } + if (showCommentUrlActions) { + MoreMenuItem(R.string.copy_link_address) { + isMenuExpanded = false + onCopyLinkClick() + } + MoreMenuItem(R.string.share_link) { + isMenuExpanded = false + onShareLinkClick() + } + } + if (status == TRASH || status == SPAM) { + MoreMenuItem(R.string.mnu_comment_delete_permanently, color = errorColor) { + isMenuExpanded = false + onDeletePermanentlyClick() + } + } + } + } +} + +@Composable +private fun MoreMenuItem( + @StringRes labelRes: Int, + color: Color = Color.Unspecified, + onClick: () -> Unit +) { + DropdownMenuItem( + text = { Text(text = stringResource(labelRes), color = color) }, + onClick = onClick + ) +} + +@Composable +private fun ActionButton( + @DrawableRes iconRes: Int, + @StringRes labelRes: Int, + isOn: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val color = if (isOn) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurface + val alpha = if (isOn) 1f else MEDIUM_EMPHASIS_ALPHA + Column( + modifier = modifier + .clickable(onClick = onClick) + .padding(horizontal = 4.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = color.copy(alpha = alpha), + modifier = Modifier.size(24.dp) + ) + Text( + text = stringResource(labelRes), + color = color.copy(alpha = alpha), + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +/** Matches material_emphasis_medium, used by the legacy footer for "off" action buttons. */ +internal const val MEDIUM_EMPHASIS_ALPHA = 0.6f + +@Preview(showBackground = true) +@Composable +private fun CommentActionFooterPreview() { + AppThemeM3 { + CommentActionFooter( + status = APPROVED, + isLiked = true, + showLikeButton = true, + showCommentUrlActions = true, + onModerateClick = {}, + onSpamClick = {}, + onLikeClick = {}, + onEditClick = {}, + onTrashClick = {}, + onCopyLinkClick = {}, + onShareLinkClick = {}, + onDeletePermanentlyClick = {} + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/compose/CommentDetailsActions.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/compose/CommentDetailsActions.kt new file mode 100644 index 000000000000..fb118cee30bf --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/compose/CommentDetailsActions.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.ui.comments.unified.compose + +/** The comment detail screen's callbacks, implemented by the hosting fragment's ViewModel. */ +@Suppress("LongParameterList") +class CommentDetailsActions( + val onModerateClick: () -> Unit, + val onSpamClick: () -> Unit, + val onLikeClick: () -> Unit, + val onEditClick: () -> Unit, + val onTrashClick: () -> Unit, + val onDeletePermanentlyClick: () -> Unit, + val onCopyLinkClick: () -> Unit, + val onShareLinkClick: () -> Unit, + val onPostTitleClick: () -> Unit, + val onSendReply: (String) -> Unit +) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/compose/CommentHtmlBody.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/compose/CommentHtmlBody.kt new file mode 100644 index 000000000000..65981df62dc5 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/compose/CommentHtmlBody.kt @@ -0,0 +1,138 @@ +package org.wordpress.android.ui.comments.unified.compose + +import android.text.style.URLSpan +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.text.HtmlCompat +import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.ui.compose.utils.toAnnotatedString +import org.wordpress.android.ui.dataview.compose.RemoteImage +import org.wordpress.android.util.EmoticonsUtils + +/** + * Renders a comment's HTML body, following the legacy CommentUtils.displayHtmlComment pipeline: + * emoticon smilies are first replaced with unicode emoji so they stay inline as text, then any + * remaining `` tags render as width-capped block images (the legacy renderer sized inline + * images to the view width, so real images effectively rendered as blocks there too), with the + * HTML between them rendered as selectable text with tappable links. + */ +@Composable +fun CommentHtmlBody(html: String, modifier: Modifier = Modifier) { + val linkColor = MaterialTheme.colorScheme.primary + val segments = remember(html) { splitCommentHtml(html) } + SelectionContainer(modifier = modifier) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + segments.forEach { segment -> + when (segment) { + is CommentBodySegment.Html -> { + val annotated = remember(segment.html, linkColor) { + commentHtmlToAnnotatedString(segment.html, linkColor) + } + if (annotated.isNotEmpty()) { + Text( + text = annotated, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp + ) + } + } + // Route through the shared RemoteImage wrapper for consistency with the + // avatars in this screen. No fallback: a failed inline image renders nothing + // (a person/broken-image placeholder would be wrong for body content). + is CommentBodySegment.Image -> RemoteImage( + imageUrl = segment.url, + contentScale = ContentScale.Inside, + alignment = Alignment.TopStart, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } +} + +internal sealed class CommentBodySegment { + data class Html(val html: String) : CommentBodySegment() + data class Image(val url: String) : CommentBodySegment() +} + +private val IMG_TAG_PATTERN = Regex( + """]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>""", + RegexOption.IGNORE_CASE +) + +/** + * Splits comment HTML into text and image segments. Emoticon smilies are converted to unicode + * emoji first — same order as the legacy displayHtmlComment, which prevented smilies from being + * downloaded as images — so only real images remain as `` tags. + */ +internal fun splitCommentHtml(html: String): List { + val withEmoji = EmoticonsUtils.replaceEmoticonsWithEmoji(html) + val segments = mutableListOf() + var consumedUpTo = 0 + IMG_TAG_PATTERN.findAll(withEmoji).forEach { match -> + val precedingHtml = withEmoji.substring(consumedUpTo, match.range.first) + if (precedingHtml.isNotBlank()) { + segments.add(CommentBodySegment.Html(precedingHtml)) + } + segments.add(CommentBodySegment.Image(match.groupValues[1])) + consumedUpTo = match.range.last + 1 + } + val remainingHtml = withEmoji.substring(consumedUpTo) + if (remainingHtml.isNotBlank()) { + segments.add(CommentBodySegment.Html(remainingHtml)) + } + return segments +} + +/** + * Renders an HTML fragment as an [AnnotatedString] with tappable, link-styled URLs and trimmed + * surrounding whitespace. + */ +internal fun commentHtmlToAnnotatedString(html: String, linkColor: Color): AnnotatedString { + val spanned = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY) + val withLinks = buildAnnotatedString { + append(spanned.toAnnotatedString()) + spanned.getSpans(0, spanned.length, URLSpan::class.java).forEach { span -> + val start = spanned.getSpanStart(span) + val end = spanned.getSpanEnd(span) + addStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline), start, end) + addLink(LinkAnnotation.Url(span.url), start, end) + } + } + // HtmlCompat pads block elements with trailing newlines; trim without breaking span offsets + val text = withLinks.text + val start = text.indexOfFirst { !it.isWhitespace() } + if (start == -1) return AnnotatedString("") + val end = text.indexOfLast { !it.isWhitespace() } + 1 + return if (start == 0 && end == text.length) withLinks else withLinks.subSequence(start, end) +} + +@Preview(showBackground = true) +@Composable +private fun CommentHtmlBodyPreview() { + AppThemeM3 { + CommentHtmlBody( + html = "Nice post, see this!" + + "And a closing thought." + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/compose/CommentReplyBox.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/compose/CommentReplyBox.kt new file mode 100644 index 000000000000..35612ccf3f62 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/compose/CommentReplyBox.kt @@ -0,0 +1,319 @@ +package org.wordpress.android.ui.comments.unified.compose + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +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.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import org.wordpress.android.R +import org.wordpress.android.ui.dataview.compose.RemoteImage +import org.wordpress.android.ui.suggestion.Suggestion + +/** + * The reply box pinned to the bottom of the comment detail: expand-to-full-screen affordance, + * multi-line reply field with `@`-mention suggestions, and a send button that swaps for a + * progress indicator while the reply is in flight. Mirrors the legacy reader_include_comment_box. + */ +@Composable +@Suppress("LongParameterList") +fun CommentReplyBox( + replyText: TextFieldValue, + onReplyTextChange: (TextFieldValue) -> Unit, + suggestions: List, + hint: String, + isReplyInProgress: Boolean, + focusOnLaunch: Boolean, + onSendClick: () -> Unit, + onExpandClick: () -> Unit, + modifier: Modifier = Modifier +) { + val focusRequester = remember { FocusRequester() } + var isReplyFieldFocused by remember { mutableStateOf(false) } + val canSend = replyText.text.isNotBlank() && !isReplyInProgress + + Column(modifier = modifier.fillMaxWidth()) { + // Only suggest mentions while the field is focused, so a restored draft that happens to + // end in an @-token doesn't pop the panel the moment the screen opens. + if (isReplyFieldFocused) { + MentionSuggestionPanel(replyText, suggestions, onReplyTextChange) + } + HorizontalDivider() + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onExpandClick) { + Icon( + painter = painterResource(R.drawable.ic_chevron_up_white_24dp), + contentDescription = stringResource(R.string.description_expand), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = MEDIUM_EMPHASIS_ALPHA) + ) + } + ReplyTextField( + replyText = replyText, + onReplyTextChange = onReplyTextChange, + hint = hint, + enabled = !isReplyInProgress, + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester) + .onFocusChanged { isReplyFieldFocused = it.isFocused } + ) + if (isReplyInProgress) { + CircularProgressIndicator( + modifier = Modifier + .padding(horizontal = 12.dp) + .size(24.dp) + ) + } else { + IconButton(onClick = onSendClick, enabled = canSend) { + Icon( + painter = painterResource(R.drawable.ic_send_white_24dp), + contentDescription = stringResource(R.string.send), + tint = if (canSend) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = MEDIUM_EMPHASIS_ALPHA) + } + ) + } + } + } + } + + if (focusOnLaunch) { + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } +} + +/** + * A full-screen version of the reply field, opened from the reply box's expand affordance. + * Shares [replyText] with the inline field so text and cursor survive expand/collapse, replacing + * the legacy CollapseFullScreenDialogFragment + CommentFullScreenDialogFragment pair. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +@Suppress("LongParameterList") +fun FullScreenReplyDialog( + replyText: TextFieldValue, + onReplyTextChange: (TextFieldValue) -> Unit, + suggestions: List, + hint: String, + isReplyInProgress: Boolean, + onSendClick: () -> Unit, + onCollapseClick: () -> Unit +) { + val focusRequester = remember { FocusRequester() } + var isReplyFieldFocused by remember { mutableStateOf(false) } + val canSend = replyText.text.isNotBlank() && !isReplyInProgress + Dialog( + onDismissRequest = onCollapseClick, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + TopAppBar( + title = { Text(stringResource(R.string.comment)) }, + navigationIcon = { + IconButton(onClick = onCollapseClick) { + Icon( + painter = painterResource(R.drawable.ic_chevron_down_white_24dp), + contentDescription = stringResource(R.string.description_collapse) + ) + } + }, + actions = { + TextButton(onClick = onSendClick, enabled = canSend) { + Text(stringResource(R.string.send)) + } + } + ) + ReplyTextField( + replyText = replyText, + onReplyTextChange = onReplyTextChange, + hint = hint, + enabled = !isReplyInProgress, + singleLineHeight = false, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .focusRequester(focusRequester) + .onFocusChanged { isReplyFieldFocused = it.isFocused } + ) + if (isReplyFieldFocused) { + MentionSuggestionPanel(replyText, suggestions, onReplyTextChange) + } + } + } + } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@Composable +private fun ReplyTextField( + replyText: TextFieldValue, + onReplyTextChange: (TextFieldValue) -> Unit, + hint: String, + enabled: Boolean, + modifier: Modifier = Modifier, + singleLineHeight: Boolean = true +) { + TextField( + value = replyText, + onValueChange = onReplyTextChange, + enabled = enabled, + placeholder = { Text(hint) }, + textStyle = MaterialTheme.typography.bodyLarge, + minLines = if (singleLineHeight) 1 else 2, + maxLines = if (singleLineHeight) 4 else Int.MAX_VALUE, + // No IME send action: the legacy field was textMultiLine, so the enter key inserts a + // newline and sending stays on the dedicated send button. + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ), + modifier = modifier + ) +} + +/** + * User suggestions for the `@`-mention being typed at the cursor, shown directly above the reply + * field. Replaces the popup the legacy SuggestionAutoCompleteText offered; matching and filtering + * follow the legacy SuggestionAdapter/SuggestionTokenizer behaviour. + */ +@Composable +private fun MentionSuggestionPanel( + replyText: TextFieldValue, + suggestions: List, + onReplyTextChange: (TextFieldValue) -> Unit +) { + val token = findMentionToken(replyText) ?: return + val filtered = filterMentionSuggestions(suggestions, token.query) + if (filtered.isEmpty()) return + + HorizontalDivider() + LazyColumn(modifier = Modifier.heightIn(max = 200.dp)) { + items(filtered, key = { it.value }) { suggestion -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + onReplyTextChange(applyMentionSuggestion(replyText, token, suggestion)) + } + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + RemoteImage( + imageUrl = suggestion.avatarUrl, + fallbackImageRes = R.drawable.ic_user_placeholder_primary_24, + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + ) + Column(modifier = Modifier.padding(start = 12.dp)) { + Text( + text = "@${suggestion.value}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = suggestion.displayValue, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = MEDIUM_EMPHASIS_ALPHA) + ) + } + } + } + } +} + +internal data class MentionToken(val start: Int, val query: String) + +/** + * Finds the `@`-mention token the cursor is currently inside: an `@` at the start of the text or + * preceded by whitespace, with no whitespace between it and the cursor. Returns null when the + * cursor isn't in a mention (or a selection is active). + */ +internal fun findMentionToken(value: TextFieldValue): MentionToken? { + val cursor = value.selection.end + val text = value.text + if (!value.selection.collapsed || cursor > text.length) return null + // The candidate token is whatever sits between the last whitespace and the cursor + val beforeCursor = text.substring(0, cursor) + val tokenStart = beforeCursor.indexOfLast { it.isWhitespace() } + 1 + val candidate = beforeCursor.substring(tokenStart) + return if (candidate.startsWith('@')) { + MentionToken(start = tokenStart, query = candidate.substring(1)) + } else { + null + } +} + +/** Same matching as the legacy SuggestionAdapter: user login or display name prefix, per word. */ +internal fun filterMentionSuggestions(suggestions: List, query: String): List { + if (query.isEmpty()) return suggestions + val lowerQuery = query.lowercase() + return suggestions.filter { suggestion -> + suggestion.value.lowercase().startsWith(lowerQuery) || + suggestion.displayValue.lowercase().startsWith(lowerQuery) || + suggestion.displayValue.lowercase().contains(" $lowerQuery") + } +} + +/** Replaces the mention being typed with the picked suggestion and moves the cursor after it. */ +internal fun applyMentionSuggestion( + value: TextFieldValue, + token: MentionToken, + suggestion: Suggestion +): TextFieldValue { + val replacement = "@${suggestion.value} " + val newText = value.text.replaceRange(token.start, value.selection.end, replacement) + return value.copy(text = newText, selection = TextRange(token.start + replacement.length)) +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/compose/UnifiedCommentDetailsScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/compose/UnifiedCommentDetailsScreen.kt new file mode 100644 index 000000000000..6e7cf67071e2 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/compose/UnifiedCommentDetailsScreen.kt @@ -0,0 +1,306 @@ +package org.wordpress.android.ui.comments.unified.compose + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.CommentStatus +import org.wordpress.android.fluxc.model.CommentStatus.APPROVED +import org.wordpress.android.fluxc.model.CommentStatus.SPAM +import org.wordpress.android.fluxc.model.CommentStatus.TRASH +import org.wordpress.android.fluxc.model.CommentStatus.UNAPPROVED +import org.wordpress.android.ui.comments.unified.UnifiedCommentDetailsViewModel.CommentDetailsUiState +import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.ui.dataview.compose.RemoteImage +import org.wordpress.android.ui.suggestion.Suggestion + +/** + * The unified (wordpress-rs) comment detail screen: comment content in a weighted scrollable + * region so the action footer and reply box stay pinned to the bottom while loading, plus the + * trash/delete confirmation dialogs and the full-screen reply editor. + */ +@Composable +@Suppress("LongParameterList") +fun UnifiedCommentDetailsScreen( + uiState: CommentDetailsUiState, + replyText: TextFieldValue, + onReplyTextChange: (TextFieldValue) -> Unit, + suggestions: List, + showLikeButton: Boolean, + focusReplyFieldOnLaunch: Boolean, + snackbarHostState: SnackbarHostState, + actions: CommentDetailsActions, + modifier: Modifier = Modifier +) { + var showTrashConfirm by rememberSaveable { mutableStateOf(false) } + var showDeleteConfirm by rememberSaveable { mutableStateOf(false) } + var showFullScreenReply by rememberSaveable { mutableStateOf(false) } + + val replyHint = if (uiState.authorName.isNotBlank()) { + stringResource(R.string.comment_reply_to_user, uiState.authorName) + } else { + stringResource(R.string.reader_hint_comment_on_post) + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + modifier = modifier + ) { contentPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + ) { + Column(modifier = Modifier.fillMaxSize()) { + // The weighted box keeps its space while the comment loads, pinning the action + // footer and reply box to the bottom (the XML layout used INVISIBLE for this). + Box(modifier = Modifier.weight(1f)) { + if (uiState.contentVisible) { + CommentDetailsContent( + uiState = uiState, + onPostTitleClick = actions.onPostTitleClick, + modifier = Modifier + .fillMaxSize() + .nestedScroll(rememberNestedScrollInteropConnection()) + .verticalScroll(rememberScrollState()) + ) + } + } + CommentActionFooter( + status = uiState.status, + isLiked = uiState.isLiked, + showLikeButton = showLikeButton, + showCommentUrlActions = uiState.commentUrl.isNotEmpty(), + onModerateClick = actions.onModerateClick, + onSpamClick = actions.onSpamClick, + onLikeClick = actions.onLikeClick, + onEditClick = actions.onEditClick, + // Trashing is committed server-side immediately (no undo affordance like the + // legacy list flow), so confirm it first; restoring needs no confirmation. + onTrashClick = { + if (uiState.status == TRASH) actions.onTrashClick() else showTrashConfirm = true + }, + onCopyLinkClick = actions.onCopyLinkClick, + onShareLinkClick = actions.onShareLinkClick, + onDeletePermanentlyClick = { showDeleteConfirm = true } + ) + CommentReplyBox( + replyText = replyText, + onReplyTextChange = onReplyTextChange, + suggestions = suggestions, + hint = replyHint, + isReplyInProgress = uiState.isReplyInProgress, + focusOnLaunch = focusReplyFieldOnLaunch, + onSendClick = { actions.onSendReply(replyText.text) }, + onExpandClick = { showFullScreenReply = true } + ) + } + if (uiState.showProgress) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + } + + if (showTrashConfirm) { + ConfirmDialog( + messageRes = R.string.dlg_confirm_trash_comments, + confirmRes = R.string.dlg_confirm_action_trash, + onConfirm = { + showTrashConfirm = false + actions.onTrashClick() + }, + onDismiss = { showTrashConfirm = false } + ) + } + + if (showDeleteConfirm) { + ConfirmDialog( + messageRes = R.string.dlg_sure_to_delete_comment, + confirmRes = R.string.delete, + onConfirm = { + showDeleteConfirm = false + actions.onDeletePermanentlyClick() + }, + onDismiss = { showDeleteConfirm = false } + ) + } + + if (showFullScreenReply) { + FullScreenReplyDialog( + replyText = replyText, + onReplyTextChange = onReplyTextChange, + suggestions = suggestions, + hint = replyHint, + isReplyInProgress = uiState.isReplyInProgress, + onSendClick = { + showFullScreenReply = false + actions.onSendReply(replyText.text) + }, + onCollapseClick = { showFullScreenReply = false } + ) + } +} + +@Composable +private fun CommentDetailsContent( + uiState: CommentDetailsUiState, + onPostTitleClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + RemoteImage( + imageUrl = uiState.authorAvatarUrl, + fallbackImageRes = R.drawable.ic_user_placeholder_primary_24, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 12.dp) + ) { + Text( + text = uiState.authorName, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = uiState.datePublished, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = MEDIUM_EMPHASIS_ALPHA), + fontSize = 12.sp + ) + } + CommentStatusLabel(uiState.status) + } + + if (uiState.postTitle.isNotBlank()) { + Text( + text = uiState.postTitle, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = MEDIUM_EMPHASIS_ALPHA), + fontSize = 14.sp, + fontStyle = FontStyle.Italic, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onPostTitleClick) + .padding(top = 16.dp) + ) + } + + CommentHtmlBody( + html = uiState.commentText, + modifier = Modifier.padding(top = 16.dp) + ) + } +} + +@Composable +private fun CommentStatusLabel(status: CommentStatus) { + val labelRes = when (status) { + APPROVED -> R.string.comment_status_approved + UNAPPROVED -> R.string.comment_status_unapproved + SPAM -> R.string.comment_status_spam + TRASH -> R.string.comment_status_trash + else -> R.string.comment_status_all + } + val isDestructive = status == TRASH || status == SPAM + Text( + text = stringResource(labelRes), + color = if (isDestructive) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = MEDIUM_EMPHASIS_ALPHA) + }, + fontSize = 12.sp + ) +} + +@Composable +private fun ConfirmDialog( + messageRes: Int, + confirmRes: Int, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + text = { Text(stringResource(messageRes)) }, + confirmButton = { + TextButton(onClick = onConfirm) { Text(stringResource(confirmRes)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + } + ) +} + +@Preview(showBackground = true) +@Composable +private fun UnifiedCommentDetailsScreenPreview() { + AppThemeM3 { + UnifiedCommentDetailsScreen( + uiState = CommentDetailsUiState( + contentVisible = true, + authorName = "Jane Doe", + datePublished = "2 hours ago", + commentText = "This is a great post, thanks for sharing!", + postTitle = "My first post", + commentUrl = "https://example.com/post#comment-1", + status = UNAPPROVED + ), + replyText = TextFieldValue(""), + onReplyTextChange = {}, + suggestions = emptyList(), + showLikeButton = true, + focusReplyFieldOnLaunch = false, + snackbarHostState = SnackbarHostState(), + actions = CommentDetailsActions( + onModerateClick = {}, + onSpamClick = {}, + onLikeClick = {}, + onEditClick = {}, + onTrashClick = {}, + onDeletePermanentlyClick = {}, + onCopyLinkClick = {}, + onShareLinkClick = {}, + onPostTitleClick = {}, + onSendReply = {} + ) + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/dataview/compose/RemoteImage.kt b/WordPress/src/main/java/org/wordpress/android/ui/dataview/compose/RemoteImage.kt index dcbdec6c231e..fec15e8d3b12 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/dataview/compose/RemoteImage.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/dataview/compose/RemoteImage.kt @@ -4,7 +4,9 @@ import android.content.Context import android.util.TypedValue import androidx.compose.foundation.Image import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import coil.compose.AsyncImage @@ -15,37 +17,49 @@ import android.content.res.Resources @Composable fun RemoteImage( imageUrl: String?, - fallbackImageRes: Int, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + fallbackImageRes: Int? = null, + contentScale: ContentScale = ContentScale.Fit, + alignment: Alignment = Alignment.Center ) { if (imageUrl.isNullOrBlank()) { - Image( - painter = painterResource(id = fallbackImageRes), - contentDescription = null, - modifier = modifier - ) + // No image and no fallback: render nothing (e.g. an inline body image with no placeholder). + if (fallbackImageRes != null) { + Image( + painter = painterResource(id = fallbackImageRes), + contentDescription = null, + alignment = alignment, + contentScale = contentScale, + modifier = modifier + ) + } } else if (imageUrl.startsWith("drawable:")) { // Handle drawable resource ID passed as string val resourceId = imageUrl.removePrefix("drawable:").toIntOrNull() - Image( - painter = painterResource( - id = if (resourceId != null && isValidDrawableId(LocalContext.current, resourceId)) { - resourceId - } else { - fallbackImageRes - } - ), - contentDescription = null, - modifier = modifier - ) + val painterRes = if (resourceId != null && isValidDrawableId(LocalContext.current, resourceId)) { + resourceId + } else { + fallbackImageRes + } + if (painterRes != null) { + Image( + painter = painterResource(id = painterRes), + contentDescription = null, + alignment = alignment, + contentScale = contentScale, + modifier = modifier + ) + } } else { AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(imageUrl) - .error(fallbackImageRes) + .apply { fallbackImageRes?.let { error(it) } } .crossfade(true) .build(), contentDescription = null, + alignment = alignment, + contentScale = contentScale, modifier = modifier ) } diff --git a/WordPress/src/main/res/layout/unified_comment_details_fragment.xml b/WordPress/src/main/res/layout/unified_comment_details_fragment.xml deleted file mode 100644 index a19675a4085d..000000000000 --- a/WordPress/src/main/res/layout/unified_comment_details_fragment.xml +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/src/main/res/menu/unified_comment_details_more.xml b/WordPress/src/main/res/menu/unified_comment_details_more.xml deleted file mode 100644 index e3d8ef3fe585..000000000000 --- a/WordPress/src/main/res/menu/unified_comment_details_more.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - diff --git a/WordPress/src/main/res/values/ids.xml b/WordPress/src/main/res/values/ids.xml index f3535f0e5f30..4bc6e3180e14 100644 --- a/WordPress/src/main/res/values/ids.xml +++ b/WordPress/src/main/res/values/ids.xml @@ -23,4 +23,5 @@ +