package org.wordpress.android.ui.comments; import android.app.Activity; import android.app.AlertDialog; import android.app.Fragment; import android.app.FragmentManager; import android.app.FragmentTransaction; import android.content.DialogInterface; import android.content.Intent; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.content.ContextCompat; import android.text.Html; import android.text.TextUtils; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.inputmethod.EditorInfo; import android.widget.ImageView; import android.widget.ProgressBar; import android.widget.ScrollView; import android.widget.TextView; import org.apache.commons.lang3.StringEscapeUtils; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import org.wordpress.android.R; import org.wordpress.android.WordPress; import org.wordpress.android.analytics.AnalyticsTracker; import org.wordpress.android.analytics.AnalyticsTracker.Stat; import org.wordpress.android.datasets.NotificationsTable; import org.wordpress.android.datasets.ReaderPostTable; import org.wordpress.android.datasets.SuggestionTable; import org.wordpress.android.fluxc.Dispatcher; import org.wordpress.android.fluxc.action.CommentAction; import org.wordpress.android.fluxc.generated.CommentActionBuilder; import org.wordpress.android.fluxc.model.CommentModel; import org.wordpress.android.fluxc.model.CommentStatus; import org.wordpress.android.fluxc.model.SiteModel; import org.wordpress.android.fluxc.store.AccountStore; import org.wordpress.android.fluxc.store.CommentStore; import org.wordpress.android.fluxc.store.CommentStore.OnCommentChanged; import org.wordpress.android.fluxc.store.CommentStore.RemoteCommentPayload; import org.wordpress.android.fluxc.store.CommentStore.RemoteCreateCommentPayload; import org.wordpress.android.fluxc.store.CommentStore.RemoteLikeCommentPayload; import org.wordpress.android.fluxc.store.SiteStore; import org.wordpress.android.fluxc.tools.FluxCImageLoader; import org.wordpress.android.models.Note; import org.wordpress.android.models.Note.EnabledActions; import org.wordpress.android.models.Suggestion; import org.wordpress.android.ui.ActivityId; import org.wordpress.android.ui.comments.CommentActions.ChangeType; import org.wordpress.android.ui.comments.CommentActions.OnCommentActionListener; import org.wordpress.android.ui.comments.CommentActions.OnCommentChangeListener; import org.wordpress.android.ui.comments.CommentActions.OnNoteCommentActionListener; import org.wordpress.android.ui.notifications.NotificationFragment; import org.wordpress.android.ui.notifications.NotificationsDetailListFragment; import org.wordpress.android.ui.reader.ReaderActivityLauncher; import org.wordpress.android.ui.reader.ReaderAnim; import org.wordpress.android.ui.reader.actions.ReaderActions; import org.wordpress.android.ui.reader.actions.ReaderPostActions; import org.wordpress.android.ui.suggestion.adapters.SuggestionAdapter; import org.wordpress.android.ui.suggestion.service.SuggestionEvents; import org.wordpress.android.ui.suggestion.util.SuggestionServiceConnectionManager; import org.wordpress.android.ui.suggestion.util.SuggestionUtils; import org.wordpress.android.util.AniUtils; import org.wordpress.android.util.AppLog; import org.wordpress.android.util.AppLog.T; import org.wordpress.android.util.DateTimeUtils; import org.wordpress.android.util.EditTextUtils; import org.wordpress.android.util.GravatarUtils; import org.wordpress.android.util.HtmlUtils; import org.wordpress.android.util.NetworkUtils; import org.wordpress.android.util.SiteUtils; import org.wordpress.android.util.ToastUtils; import org.wordpress.android.util.WPLinkMovementMethod; import org.wordpress.android.widgets.SuggestionAutoCompleteText; import org.wordpress.android.widgets.WPNetworkImageView; import java.util.EnumSet; import java.util.List; import java.util.Locale; import javax.inject.Inject; import de.greenrobot.event.EventBus; /** * comment detail displayed from both the notification list and the comment list * prior to this there were separate comment detail screens for each list */ public class CommentDetailFragment extends Fragment implements NotificationFragment { private static final String KEY_MODE = "KEY_MODE"; private static final String KEY_COMMENT = "KEY_COMMENT"; private static final String KEY_NOTE_ID = "KEY_NOTE_ID"; private static final String KEY_REPLY_TEXT = "KEY_REPLY_TEXT"; private static final String KEY_FRAGMENT_CONTAINER_ID = "KEY_FRAGMENT_CONTAINER_ID"; private static final int INTENT_COMMENT_EDITOR = 1010; private static final int FROM_BLOG_COMMENT = 1; private static final int FROM_NOTE = 2; private CommentModel mComment; private SiteModel mSite; private Note mNote; private int mIdForFragmentContainer; private SuggestionAdapter mSuggestionAdapter; private SuggestionServiceConnectionManager mSuggestionServiceConnectionManager; private TextView mTxtStatus; private TextView mTxtContent; private View mSubmitReplyBtn; private SuggestionAutoCompleteText mEditReply; private ViewGroup mLayoutReply; private ViewGroup mLayoutButtons; private ViewGroup mCommentContentLayout; private View mBtnLikeComment; private ImageView mBtnLikeIcon; private TextView mBtnLikeTextView; private View mBtnModerateComment; private View mBtnEditComment; private ImageView mBtnModerateIcon; private TextView mBtnModerateTextView; private View mBtnSpamComment; private TextView mBtnSpamCommentText; private View mBtnTrashComment; private TextView mBtnTrashCommentText; private String mRestoredReplyText; private String mRestoredNoteId; private boolean mIsUsersBlog = false; private boolean mShouldFocusReplyField; private String mPreviousStatus; private long mCommentIdToFetch; @Inject Dispatcher mDispatcher; @Inject AccountStore mAccountStore; @Inject CommentStore mCommentStore; @Inject SiteStore mSiteStore; @Inject FluxCImageLoader mImageLoader; private boolean mIsSubmittingReply = false; private NotificationsDetailListFragment mNotificationsDetailListFragment; private OnCommentChangeListener mOnCommentChangeListener; private OnPostClickListener mOnPostClickListener; private OnCommentActionListener mOnCommentActionListener; private OnNoteCommentActionListener mOnNoteCommentActionListener; /* * these determine which actions (moderation, replying, marking as spam) to enable * for this comment - all actions are enabled when opened from the comment list, only * changed when opened from a notification */ private EnumSet<EnabledActions> mEnabledActions = EnumSet.allOf(EnabledActions.class); /* * used when called from comment list */ static CommentDetailFragment newInstance(SiteModel site, CommentModel comment) { CommentDetailFragment fragment = new CommentDetailFragment(); Bundle args = new Bundle(); args.putInt(KEY_MODE, FROM_BLOG_COMMENT); args.putSerializable(WordPress.SITE, site); args.putSerializable(KEY_COMMENT, comment); fragment.setArguments(args); return fragment; } /* * used when called from notification list for a comment notification */ public static CommentDetailFragment newInstance(final String noteId, final String replyText, final int idForFragmentContainer) { CommentDetailFragment fragment = new CommentDetailFragment(); Bundle args = new Bundle(); args.putInt(KEY_MODE, FROM_NOTE); args.putString(KEY_NOTE_ID, noteId); args.putString(KEY_REPLY_TEXT, replyText); args.putInt(KEY_FRAGMENT_CONTAINER_ID, idForFragmentContainer + R.id.note_comment_fragment_container_base_id); fragment.setArguments(args); return fragment; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ((WordPress) getActivity().getApplication()).component().inject(this); switch (getArguments().getInt(KEY_MODE)) { case FROM_BLOG_COMMENT: setComment((CommentModel) getArguments().getSerializable(KEY_COMMENT), (SiteModel) getArguments().getSerializable(WordPress.SITE)); break; case FROM_NOTE: setNote(getArguments().getString(KEY_NOTE_ID)); setReplyText(getArguments().getString(KEY_REPLY_TEXT)); setIdForFragmentContainer(getArguments().getInt(KEY_FRAGMENT_CONTAINER_ID)); break; } if (savedInstanceState != null) { mIdForFragmentContainer = savedInstanceState.getInt(KEY_FRAGMENT_CONTAINER_ID); if (savedInstanceState.getString(KEY_NOTE_ID) != null) { // The note will be set in onResume() // See WordPress.deferredInit() mRestoredNoteId = savedInstanceState.getString(KEY_NOTE_ID); } else { SiteModel site = (SiteModel) savedInstanceState.getSerializable(WordPress.SITE); CommentModel comment = (CommentModel) savedInstanceState.getSerializable(KEY_COMMENT); setComment(comment, site); } } setHasOptionsMenu(true); } @Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); if (mComment != null) { outState.putSerializable(WordPress.SITE, mSite); outState.putSerializable(KEY_COMMENT, mComment); } if (mNote != null) { outState.putString(KEY_NOTE_ID, mNote.getId()); } outState.putInt(KEY_FRAGMENT_CONTAINER_ID, mIdForFragmentContainer); } @Override public void onDestroy() { if (mSuggestionServiceConnectionManager != null) { mSuggestionServiceConnectionManager.unbindFromService(); } super.onDestroy(); } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { final View view = inflater.inflate(R.layout.comment_detail_fragment, container, false); mTxtStatus = (TextView) view.findViewById(R.id.text_status); mTxtContent = (TextView) view.findViewById(R.id.text_content); mLayoutButtons = (ViewGroup) inflater.inflate(R.layout.comment_action_footer, null, false); mBtnLikeComment = mLayoutButtons.findViewById(R.id.btn_like); mBtnLikeIcon = (ImageView) mLayoutButtons.findViewById(R.id.btn_like_icon); mBtnLikeTextView = (TextView) mLayoutButtons.findViewById(R.id.btn_like_text); mBtnModerateComment = mLayoutButtons.findViewById(R.id.btn_moderate); mBtnModerateIcon = (ImageView) mLayoutButtons.findViewById(R.id.btn_moderate_icon); mBtnModerateTextView = (TextView) mLayoutButtons.findViewById(R.id.btn_moderate_text); mBtnEditComment = mLayoutButtons.findViewById(R.id.btn_edit); mBtnSpamComment = mLayoutButtons.findViewById(R.id.btn_spam); mBtnSpamCommentText = (TextView) mLayoutButtons.findViewById(R.id.btn_spam_text); mBtnTrashComment = mLayoutButtons.findViewById(R.id.btn_trash); mBtnTrashCommentText = (TextView) mLayoutButtons.findViewById(R.id.btn_trash_text); // As we are using CommentDetailFragment in a ViewPager, and we also use nested fragments within // CommentDetailFragment itself: // It is important to have a live reference to the Comment Container layout at the moment this layout is // inflated (onCreateView), so we can make sure we set its ID correctly once we have an actual Comment object // to populate it with. Otherwise, we could be searching and finding the container for _another fragment/page // in the viewpager_, which would cause strange results (changing the views for a different fragment than we // intended to). mCommentContentLayout = (ViewGroup) view.findViewById(R.id.comment_content_container); mLayoutReply = (ViewGroup) view.findViewById(R.id.layout_comment_box); mEditReply = (SuggestionAutoCompleteText) mLayoutReply.findViewById(R.id.edit_comment); setReplyUniqueId(); mSubmitReplyBtn = mLayoutReply.findViewById(R.id.btn_submit_reply); // hide comment like button until we know it can be enabled in showCommentAsNotification() mBtnLikeComment.setVisibility(View.GONE); // hide moderation buttons until updateModerationButtons() is called mLayoutButtons.setVisibility(View.GONE); // this is necessary in order for anchor tags in the comment text to be clickable mTxtContent.setLinksClickable(true); mTxtContent.setMovementMethod(WPLinkMovementMethod.getInstance()); mEditReply.setHint(R.string.reader_hint_comment_on_comment); mEditReply.setOnEditorActionListener(new TextView.OnEditorActionListener() { @Override public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { if (actionId == EditorInfo.IME_ACTION_DONE || actionId == EditorInfo.IME_ACTION_SEND) submitReply(); return false; } }); if (!TextUtils.isEmpty(mRestoredReplyText)) { mEditReply.setText(mRestoredReplyText); mRestoredReplyText = null; } mSubmitReplyBtn.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { submitReply(); } }); mBtnSpamComment.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { if (mComment == null) return; if (CommentStatus.fromString(mComment.getStatus()) == CommentStatus.SPAM) { moderateComment(CommentStatus.APPROVED); } else { moderateComment(CommentStatus.SPAM); } } }); mBtnTrashComment.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { if (mComment == null) return; CommentStatus status = CommentStatus.fromString(mComment.getStatus()); // If the comment status is trash or spam, next deletion is a permanent deletion. if (status == CommentStatus.TRASH || status == CommentStatus.SPAM) { AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(getActivity()); dialogBuilder.setTitle(getResources().getText(R.string.delete)); dialogBuilder.setMessage(getResources().getText(R.string.dlg_sure_to_delete_comment)); dialogBuilder.setPositiveButton(getResources().getText(R.string.yes), new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int whichButton) { moderateComment(CommentStatus.DELETED); } }); dialogBuilder.setNegativeButton( getResources().getText(R.string.no), null); dialogBuilder.setCancelable(true); dialogBuilder.create().show(); } else { moderateComment(CommentStatus.TRASH); } } }); mBtnLikeComment.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { likeComment(false); } }); mBtnEditComment.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { editComment(); } }); setupSuggestionServiceAndAdapter(); return view; } @Override public void onResume() { super.onResume(); ActivityId.trackLastActivity(ActivityId.COMMENT_DETAIL); // Set the note if we retrieved the noteId from savedInstanceState if (!TextUtils.isEmpty(mRestoredNoteId)) { setNote(mRestoredNoteId); mRestoredNoteId = null; } } private void setupSuggestionServiceAndAdapter() { if (!isAdded() || mSite == null || !SiteUtils.isAccessedViaWPComRest(mSite)) { return; } mSuggestionServiceConnectionManager = new SuggestionServiceConnectionManager(getActivity(), mSite.getSiteId()); mSuggestionAdapter = SuggestionUtils.setupSuggestions(mSite, getActivity(), mSuggestionServiceConnectionManager); if (mSuggestionAdapter != null) { mEditReply.setAdapter(mSuggestionAdapter); } } private void setReplyUniqueId() { if (mEditReply != null && isAdded()) { String sId = null; if (mSite != null && mComment != null) { sId = String.format(Locale.US, "%d-%d", mSite.getSiteId(), mComment.getRemoteCommentId()); } else if (mNote != null) { sId = String.format(Locale.US, "%d-%d", mNote.getSiteId(), mNote.getCommentId()); } if (sId != null) { mEditReply.getAutoSaveTextHelper().setUniqueId(sId); mEditReply.getAutoSaveTextHelper().loadString(mEditReply); } } } private void setComment(@Nullable final CommentModel comment, @Nullable final SiteModel site) { mComment = comment; mSite = site; setIdForCommentContainer(); // is this comment on one of the user's blogs? it won't be if this was displayed from a // notification about a reply to a comment this user posted on someone else's blog mIsUsersBlog = (comment != null && site != null); if (isAdded()) { showComment(); } // Reset the reply unique id since mComment just changed. setReplyUniqueId(); } private void disableShouldFocusReplyField() { mShouldFocusReplyField = false; } public void enableShouldFocusReplyField() { mShouldFocusReplyField = true; } @Override public Note getNote() { return mNote; } private SiteModel createDummyWordPressComSite(long siteId) { SiteModel site = new SiteModel(); site.setIsWPCom(true); site.setSiteId(siteId); return site; } public void setNote(Note note) { mNote = note; mSite = mSiteStore.getSiteBySiteId(note.getSiteId()); if (mSite == null) { // This should not exist, we should clean that screen so a note without a site/comment can be displayed mSite = createDummyWordPressComSite(mNote.getSiteId()); } if (isAdded() && mNote != null) { setIdForCommentContainer(); showComment(); } } @Override public void setNote(String noteId) { if (noteId == null) { showErrorToastAndFinish(); return; } Note note = NotificationsTable.getNoteById(noteId); if (note == null) { showErrorToastAndFinish(); return; } setNote(note); } private void setIdForFragmentContainer(int id) { if (id > 0) { mIdForFragmentContainer = id; } } private void setReplyText(String replyText) { if (replyText == null) return; mRestoredReplyText = replyText; } private void showErrorToastAndFinish() { AppLog.e(AppLog.T.NOTIFS, "Note could not be found."); if (getActivity() != null) { ToastUtils.showToast(getActivity(), R.string.error_notification_open); getActivity().finish(); } } @SuppressWarnings("deprecation") // TODO: Remove when minSdkVersion >= 23 public void onAttach(Activity activity) { super.onAttach(activity); if (activity instanceof OnCommentChangeListener) { mOnCommentChangeListener = (OnCommentChangeListener) activity; } if (activity instanceof OnPostClickListener) { mOnPostClickListener = (OnPostClickListener) activity; } if (activity instanceof OnCommentActionListener) { mOnCommentActionListener = (OnCommentActionListener) activity; } if (activity instanceof OnNoteCommentActionListener) { mOnNoteCommentActionListener = (OnNoteCommentActionListener) activity; } } @Override public void onStart() { super.onStart(); EventBus.getDefault().register(this); mDispatcher.register(this); showComment(); } @Override public void onStop() { EventBus.getDefault().unregister(this); mDispatcher.unregister(this); super.onStop(); } @SuppressWarnings("unused") public void onEventMainThread(SuggestionEvents.SuggestionNameListUpdated event) { // check if the updated suggestions are for the current blog and update the suggestions if (event.mRemoteBlogId != 0 && mSite != null && event.mRemoteBlogId == mSite.getSiteId() && mSuggestionAdapter != null) { List<Suggestion> suggestions = SuggestionTable.getSuggestionsForSite(event.mRemoteBlogId); mSuggestionAdapter.setSuggestionList(suggestions); } } @Override public void onPause() { super.onPause(); } @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode == INTENT_COMMENT_EDITOR && resultCode == Activity.RESULT_OK) { reloadComment(); // tell the host to reload the comment list if (mOnCommentChangeListener != null) mOnCommentChangeListener.onCommentChanged(ChangeType.EDITED); } } /** * Reload the current comment from the local database */ private void reloadComment() { if (mComment == null) { return; } CommentModel updatedComment = mCommentStore.getCommentByLocalId(mComment.getId()); if (updatedComment != null) { setComment(updatedComment, mSite); } } /** * open the comment for editing */ private void editComment() { if (!isAdded() || mComment == null) { return; } // IMPORTANT: don't use getActivity().startActivityForResult() or else onActivityResult() // won't be called in this fragment // https://code.google.com/p/android/issues/detail?id=15394#c45 Intent intent = new Intent(getActivity(), EditCommentActivity.class); intent.putExtra(WordPress.SITE, mSite); intent.putExtra(EditCommentActivity.KEY_COMMENT, mComment); if (mNote != null && mComment == null) { intent.putExtra(EditCommentActivity.KEY_NOTE_ID, mNote.getId()); } startActivityForResult(intent, INTENT_COMMENT_EDITOR); } /* * display the current comment */ private void showComment() { if (!isAdded() || getView() == null) return; // these two views contain all the other views except the progress bar final ScrollView scrollView = (ScrollView) getView().findViewById(R.id.scroll_view); final View layoutBottom = getView().findViewById(R.id.layout_bottom); // hide container views when comment is null (will happen when opened from a notification) if (mComment == null) { scrollView.setVisibility(View.GONE); layoutBottom.setVisibility(View.GONE); if (mNote != null) { SiteModel site = mSiteStore.getSiteBySiteId(mNote.getSiteId()); if (site == null) { // This should not exist, we should clean that screen so a note without a site/comment // can be displayed site = createDummyWordPressComSite(mNote.getSiteId()); } // Check if the comment is already in our store CommentModel comment = mCommentStore.getCommentBySiteAndRemoteId(site, mNote.getCommentId()); if (comment != null) { // It exists, then show it as a "Notification" showCommentAsNotification(mNote, site, comment); } else { // It's not in our store yet, request it. RemoteCommentPayload payload = new RemoteCommentPayload(site, mNote.getCommentId()); mDispatcher.dispatch(CommentActionBuilder.newFetchCommentAction(payload)); setProgressVisible(true); // Show a "temporary" comment built from the note data, the view will be refreshed once the // comment has been fetched. showCommentAsNotification(mNote, site, null); } } return; } scrollView.setVisibility(View.VISIBLE); layoutBottom.setVisibility(View.VISIBLE); // Add action buttons footer if (mNote == null && mLayoutButtons.getParent() == null) { mCommentContentLayout.addView(mLayoutButtons); } final WPNetworkImageView imgAvatar = (WPNetworkImageView) getView().findViewById(R.id.image_avatar); final TextView txtName = (TextView) getView().findViewById(R.id.text_name); final TextView txtDate = (TextView) getView().findViewById(R.id.text_date); txtName.setText(mComment.getAuthorName() == null ? getString(R.string.anonymous) : HtmlUtils.fastUnescapeHtml(mComment.getAuthorName())); txtDate.setText(DateTimeUtils.javaDateToTimeSpan(DateTimeUtils.dateFromIso8601(mComment.getDatePublished()), WordPress.getContext())); int maxImageSz = getResources().getDimensionPixelSize(R.dimen.reader_comment_max_image_size); CommentUtils.displayHtmlComment(mTxtContent, mComment.getContent(), maxImageSz, mImageLoader); int avatarSz = getResources().getDimensionPixelSize(R.dimen.avatar_sz_large); if (mComment.getAuthorProfileImageUrl() != null) { imgAvatar.setImageUrl(GravatarUtils.fixGravatarUrl(mComment.getAuthorProfileImageUrl(), avatarSz), WPNetworkImageView.ImageType.AVATAR); } else if (mComment.getAuthorEmail() != null) { String avatarUrl = GravatarUtils.gravatarFromEmail(mComment.getAuthorEmail(), avatarSz); imgAvatar.setImageUrl(avatarUrl, WPNetworkImageView.ImageType.AVATAR); } else { imgAvatar.setImageUrl(null, WPNetworkImageView.ImageType.AVATAR); } updateStatusViews(); // navigate to author's blog when avatar or name clicked if (mComment.getAuthorUrl() != null) { View.OnClickListener authorListener = new View.OnClickListener() { @Override public void onClick(View v) { ReaderActivityLauncher.openUrl(getActivity(), mComment.getAuthorUrl()); } }; imgAvatar.setOnClickListener(authorListener); txtName.setOnClickListener(authorListener); txtName.setTextColor(ContextCompat.getColor(getActivity(), R.color.reader_hyperlink)); } else { txtName.setTextColor(ContextCompat.getColor(getActivity(), R.color.grey_darken_30)); } showPostTitle(mSite, mComment.getRemotePostId()); // make sure reply box is showing if (mLayoutReply.getVisibility() != View.VISIBLE && canReply()) { AniUtils.animateBottomBar(mLayoutReply, true); if (mEditReply != null && mShouldFocusReplyField) { mEditReply.performClick(); disableShouldFocusReplyField(); } } getFragmentManager().invalidateOptionsMenu(); } /* * displays the passed post title for the current comment, updates stored title if one doesn't exist */ private void setPostTitle(TextView txtTitle, String postTitle, boolean isHyperlink) { if (txtTitle == null || !isAdded()) { return; } if (TextUtils.isEmpty(postTitle)) { txtTitle.setText(R.string.untitled); return; } // if comment doesn't have a post title, set it to the passed one and save to comment table if (mComment != null && mComment.getPostTitle() == null) { mComment.setPostTitle(postTitle); mDispatcher.dispatch(CommentActionBuilder.newUpdateCommentAction(mComment)); } // display "on [Post Title]..." if (isHyperlink) { String html = getString(R.string.on) + " <font color=" + HtmlUtils.colorResToHtmlColor(getActivity(), R.color.reader_hyperlink) + ">" + postTitle.trim() + "</font>"; txtTitle.setText(Html.fromHtml(html)); } else { String text = getString(R.string.on) + " " + postTitle.trim(); txtTitle.setText(text); } } /* * ensure the post associated with this comment is available to the reader and show its * title above the comment */ private void showPostTitle(final SiteModel site, final long postId) { if (!isAdded()) { return; } final TextView txtPostTitle = (TextView) getView().findViewById(R.id.text_post_title); boolean postExists = ReaderPostTable.postExists(site.getSiteId(), postId); // the post this comment is on can only be requested if this is a .com blog or a // jetpack-enabled self-hosted blog, and we have valid .com credentials boolean canRequestPost = SiteUtils.isAccessedViaWPComRest(site) && mAccountStore.hasAccessToken(); final String title; final boolean hasTitle; if (mComment.getPostTitle() != null) { // use comment's stored post title if available title = mComment.getPostTitle(); hasTitle = true; } else if (postExists) { // use title from post if available title = ReaderPostTable.getPostTitle(site.getSiteId(), postId); hasTitle = !TextUtils.isEmpty(title); } else { title = null; hasTitle = false; } if (hasTitle) { setPostTitle(txtPostTitle, title, canRequestPost); } else if (canRequestPost) { txtPostTitle.setText(postExists ? R.string.untitled : R.string.loading); } // if this is a .com or jetpack blog, tapping the title shows the associated post // in the reader if (canRequestPost) { // first make sure this post is available to the reader, and once it's retrieved set // the title if it wasn't set above if (!postExists) { AppLog.d(T.COMMENTS, "comment detail > retrieving post"); ReaderPostActions.requestBlogPost(site.getSiteId(), postId, new ReaderActions.OnRequestListener() { @Override public void onSuccess() { if (!isAdded()) return; // update title if it wasn't set above if (!hasTitle) { String postTitle = ReaderPostTable.getPostTitle(site.getSiteId(), postId); if (!TextUtils.isEmpty(postTitle)) { setPostTitle(txtPostTitle, postTitle, true); } else { txtPostTitle.setText(R.string.untitled); } } } @Override public void onFailure(int statusCode) { } }); } txtPostTitle.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { if (mOnPostClickListener != null) { mOnPostClickListener.onPostClicked(getNote(), site.getSiteId(), (int) mComment.getRemotePostId()); } else { // right now this will happen from notifications AppLog.i(T.COMMENTS, "comment detail > no post click listener"); ReaderActivityLauncher.showReaderPostDetail(getActivity(), site.getSiteId(), mComment.getRemotePostId()); } } }); } } private void trackModerationFromNotification(final CommentStatus newStatus) { switch (newStatus) { case APPROVED: AnalyticsTracker.track(Stat.NOTIFICATION_APPROVED); break; case UNAPPROVED: AnalyticsTracker.track(Stat.NOTIFICATION_UNAPPROVED); break; case SPAM: AnalyticsTracker.track(Stat.NOTIFICATION_FLAGGED_AS_SPAM); break; case TRASH: AnalyticsTracker.track(Stat.NOTIFICATION_TRASHED); break; } } /* * approve, disapprove, spam, or trash the current comment */ private void moderateComment(CommentStatus newStatus) { if (!isAdded() || mComment == null) { return; } if (!NetworkUtils.checkConnection(getActivity())) { return; } mPreviousStatus = mComment.getStatus(); // Fire the appropriate listener if we have one if (mNote != null && mOnNoteCommentActionListener != null) { mOnNoteCommentActionListener.onModerateCommentForNote(mNote, newStatus); trackModerationFromNotification(newStatus); dispatchModerationAction(newStatus); } else if (mOnCommentActionListener != null) { mOnCommentActionListener.onModerateComment(mSite, mComment, newStatus); // Sad, but onModerateComment does the moderation itself (due to the undo bar), this should be refactored, // That's why we don't call dispatchModerationAction() here. } updateStatusViews(); } private void dispatchModerationAction(CommentStatus newStatus) { if (newStatus == CommentStatus.DELETED) { // For deletion, we need to dispatch a specific action. mDispatcher.dispatch(CommentActionBuilder.newDeleteCommentAction(new RemoteCommentPayload(mSite, mComment))); } else { // Actual moderation (push the modified comment). mComment.setStatus(newStatus.toString()); mDispatcher.dispatch(CommentActionBuilder.newPushCommentAction(new RemoteCommentPayload(mSite, mComment))); } } /* * post comment box text as a reply to the current comment */ private void submitReply() { if (mComment == null || !isAdded() || mIsSubmittingReply) return; if (!NetworkUtils.checkConnection(getActivity())) return; final String replyText = EditTextUtils.getText(mEditReply); if (TextUtils.isEmpty(replyText)) return; // disable editor, hide soft keyboard, hide submit icon, and show progress spinner while submitting mEditReply.setEnabled(false); EditTextUtils.hideSoftInput(mEditReply); mSubmitReplyBtn.setVisibility(View.GONE); final ProgressBar progress = (ProgressBar) getView().findViewById(R.id.progress_submit_comment); progress.setVisibility(View.VISIBLE); mIsSubmittingReply = true; AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATION_REPLIED_TO); // Pseudo comment reply CommentModel reply = new CommentModel(); reply.setContent(replyText); mDispatcher.dispatch(CommentActionBuilder.newCreateNewCommentAction(new RemoteCreateCommentPayload(mSite, mComment, reply))); } /* * update the text, drawable & click listener for mBtnModerate based on * the current status of the comment, show mBtnSpam if the comment isn't * already marked as spam, and show the current status of the comment */ private void updateStatusViews() { if (!isAdded() || mComment == null) { return; } final int statusTextResId; // string resource id for status text final int statusColor; // color for status text CommentStatus commentStatus = CommentStatus.fromString(mComment.getStatus()); switch (commentStatus) { case APPROVED: statusTextResId = R.string.comment_status_approved; statusColor = ContextCompat.getColor(getActivity(), R.color.notification_status_unapproved_dark); break; case UNAPPROVED: statusTextResId = R.string.comment_status_unapproved; statusColor = ContextCompat.getColor(getActivity(), R.color.notification_status_unapproved_dark); break; case SPAM: statusTextResId = R.string.comment_status_spam; statusColor = ContextCompat.getColor(getActivity(), R.color.comment_status_spam); break; case TRASH: default: statusTextResId = R.string.comment_status_trash; statusColor = ContextCompat.getColor(getActivity(), R.color.comment_status_spam); break; } if (canLike()) { mBtnLikeComment.setVisibility(View.VISIBLE); if (mComment != null) { toggleLikeButton(mComment.getILike()); } else if (mNote != null) { mNote.hasLikedComment(); } } // comment status is only shown if this comment is from one of this user's blogs and the // comment hasn't been CommentStatus.APPROVED if (mIsUsersBlog && commentStatus != CommentStatus.APPROVED) { mTxtStatus.setText(getString(statusTextResId).toUpperCase()); mTxtStatus.setTextColor(statusColor); if (mTxtStatus.getVisibility() != View.VISIBLE) { mTxtStatus.clearAnimation(); AniUtils.fadeIn(mTxtStatus, AniUtils.Duration.LONG); } } else { mTxtStatus.setVisibility(View.GONE); } if (canModerate()) { setModerateButtonForStatus(commentStatus); mBtnModerateComment.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { performModerateAction(); } }); mBtnModerateComment.setVisibility(View.VISIBLE); } else { mBtnModerateComment.setVisibility(View.GONE); } if (canMarkAsSpam()) { mBtnSpamComment.setVisibility(View.VISIBLE); if (commentStatus == CommentStatus.SPAM) { mBtnSpamCommentText.setText(R.string.mnu_comment_unspam); } else { mBtnSpamCommentText.setText(R.string.mnu_comment_spam); } } else { mBtnSpamComment.setVisibility(View.GONE); } if (canTrash()) { mBtnTrashComment.setVisibility(View.VISIBLE); if (commentStatus == CommentStatus.TRASH) { mBtnModerateIcon.setImageResource(R.drawable.ic_undo_grey_24dp); mBtnModerateTextView.setText(R.string.mnu_comment_untrash); mBtnTrashCommentText.setText(R.string.mnu_comment_delete_permanently); } else { mBtnTrashCommentText.setText(R.string.mnu_comment_trash); } } else { mBtnTrashComment.setVisibility(View.GONE); } if (canEdit()) { mBtnEditComment.setVisibility(View.VISIBLE); } mLayoutButtons.setVisibility(View.VISIBLE); } private void performModerateAction(){ if (mComment == null || !isAdded() || !NetworkUtils.checkConnection(getActivity())) { return; } CommentStatus newStatus = CommentStatus.APPROVED; if (CommentStatus.fromString(mComment.getStatus()) == CommentStatus.APPROVED) { newStatus = CommentStatus.UNAPPROVED; } mComment.setStatus(newStatus.toString()); setModerateButtonForStatus(newStatus); AniUtils.startAnimation(mBtnModerateIcon, R.anim.notifications_button_scale); moderateComment(newStatus); } private void setModerateButtonForStatus(CommentStatus status) { if (status == CommentStatus.APPROVED) { mBtnModerateIcon.setImageResource(R.drawable.ic_checkmark_orange_jazzy_24dp); mBtnModerateTextView.setText(R.string.comment_status_approved); mBtnModerateTextView.setTextColor(ContextCompat.getColor(getActivity(), R.color.notification_status_unapproved_dark)); } else { mBtnModerateIcon.setImageResource(R.drawable.ic_checkmark_grey_24dp); mBtnModerateTextView.setText(R.string.mnu_comment_approve); mBtnModerateTextView.setTextColor(ContextCompat.getColor(getActivity(), R.color.grey)); } } /* * does user have permission to moderate/reply/spam this comment? */ private boolean canModerate() { return mEnabledActions != null && (mEnabledActions.contains(EnabledActions.ACTION_APPROVE) || mEnabledActions.contains(EnabledActions.ACTION_UNAPPROVE)); } private boolean canMarkAsSpam() { return (mEnabledActions != null && mEnabledActions.contains(EnabledActions.ACTION_SPAM)); } private boolean canReply() { return (mEnabledActions != null && mEnabledActions.contains(EnabledActions.ACTION_REPLY)); } private boolean canTrash() { return canModerate(); } private boolean canEdit() { return (mSite != null && canModerate()); } private boolean canLike() { return (mEnabledActions != null && mEnabledActions.contains(EnabledActions.ACTION_LIKE) && mSite != null && mSite.isWPCom()); } /* * display the comment associated with the passed notification */ private void showCommentAsNotification(Note note, @NonNull SiteModel site, @Nullable CommentModel comment) { if (getView() == null) return; View view = getView(); // hide standard comment views, since we'll be adding note blocks instead View commentContent = view.findViewById(R.id.comment_content); if (commentContent != null) { commentContent.setVisibility(View.GONE); } View commentText = view.findViewById(R.id.text_content); if (commentText != null) { commentText.setVisibility(View.GONE); } /* * determine which actions to enable for this comment - if the comment is from this user's * blog then all actions will be enabled, but they won't be if it's a reply to a comment * this user made on someone else's blog */ mEnabledActions = note.getEnabledActions(); // Set 'Reply to (Name)' in comment reply EditText if it's a reasonable size if (!TextUtils.isEmpty(mNote.getCommentAuthorName()) && mNote.getCommentAuthorName().length() < 28) { mEditReply.setHint(String.format(getString(R.string.comment_reply_to_user), mNote.getCommentAuthorName())); } // adjust enabledActions if this is a Jetpack site if (canLike() && site.isJetpackConnected()) { // delete LIKE action from enabledActions for Jetpack sites mEnabledActions.remove(EnabledActions.ACTION_LIKE); } if (comment != null) { setComment(comment, site); } else { setComment(note.buildComment(), site); } addDetailFragment(note.getId()); getFragmentManager().invalidateOptionsMenu(); } private void addDetailFragment(String noteId) { // Now we'll add a detail fragment list FragmentManager fragmentManager = getFragmentManager(); FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); mNotificationsDetailListFragment = NotificationsDetailListFragment.newInstance(noteId); mNotificationsDetailListFragment.setFooterView(mLayoutButtons); fragmentTransaction.replace(mCommentContentLayout.getId(), mNotificationsDetailListFragment); fragmentTransaction.commitAllowingStateLoss(); } private void setIdForCommentContainer(){ if (mCommentContentLayout != null) { mCommentContentLayout.setId(mIdForFragmentContainer); } } // Like or unlike a comment via the REST API private void likeComment(boolean forceLike) { if (!isAdded()) { return; } if (forceLike && mBtnLikeComment.isActivated()) { return; } toggleLikeButton(!mBtnLikeComment.isActivated()); ReaderAnim.animateLikeButton(mBtnLikeIcon, mBtnLikeComment.isActivated()); // Bump analytics AnalyticsTracker.track(mBtnLikeComment.isActivated() ? Stat.NOTIFICATION_LIKED : Stat.NOTIFICATION_UNLIKED); if (mNotificationsDetailListFragment != null && mComment != null) { // Optimistically set comment to approved when liking an unapproved comment // WP.com will set a comment to approved if it is liked while unapproved if (mBtnLikeComment.isActivated() && CommentStatus.fromString(mComment.getStatus()) == CommentStatus.UNAPPROVED) { mComment.setStatus(CommentStatus.APPROVED.toString()); mNotificationsDetailListFragment.refreshBlocksForCommentStatus(CommentStatus.APPROVED); setModerateButtonForStatus(CommentStatus.APPROVED); } } mDispatcher.dispatch(CommentActionBuilder.newLikeCommentAction( new RemoteLikeCommentPayload(mSite, mComment, mBtnLikeComment.isActivated()))); } private void toggleLikeButton(boolean isLiked) { if (isLiked) { mBtnLikeTextView.setText(getResources().getString(R.string.mnu_comment_liked)); mBtnLikeTextView.setTextColor(ContextCompat.getColor(getActivity(), R.color.orange_jazzy)); mBtnLikeIcon.setImageDrawable(ContextCompat.getDrawable(getActivity(), R.drawable.ic_star_orange_jazzy_24dp)); mBtnLikeComment.setActivated(true); } else { mBtnLikeTextView.setText(getResources().getString(R.string.reader_label_like)); mBtnLikeTextView.setTextColor(ContextCompat.getColor(getActivity(), R.color.grey)); mBtnLikeIcon.setImageDrawable(ContextCompat.getDrawable(getActivity(), R.drawable.ic_star_outline_grey_24dp)); mBtnLikeComment.setActivated(false); } } private void setProgressVisible(boolean visible) { final ProgressBar progress = (isAdded() && getView() != null ? (ProgressBar) getView().findViewById(R.id.progress_loading) : null); if (progress != null) { progress.setVisibility(visible ? View.VISIBLE : View.GONE); } } private void onCommentModerated(OnCommentChanged event) { if (!isAdded()) return; if (event.isError()) { mComment.setStatus(mPreviousStatus); updateStatusViews(); ToastUtils.showToast(getActivity(), R.string.error_moderate_comment); } else { reloadComment(); } } private void onCommentCreated(OnCommentChanged event) { mIsSubmittingReply = false; mEditReply.setEnabled(true); mSubmitReplyBtn.setVisibility(View.VISIBLE); getView().findViewById(R.id.progress_submit_comment).setVisibility(View.GONE); updateStatusViews(); if (event.isError()) { if (isAdded()) { String strUnEscapeHTML = StringEscapeUtils.unescapeHtml4(event.error.message); ToastUtils.showToast(getActivity(), strUnEscapeHTML, ToastUtils.Duration.LONG); // refocus editor on failure and show soft keyboard EditTextUtils.showSoftInput(mEditReply); } return; } if (mOnCommentChangeListener != null) { mOnCommentChangeListener.onCommentChanged(ChangeType.REPLIED); } if (isAdded()) { ToastUtils.showToast(getActivity(), getString(R.string.note_reply_successful)); mEditReply.setText(null); mEditReply.getAutoSaveTextHelper().clearSavedText(mEditReply); } // approve the comment if (mComment != null && !(CommentStatus.fromString(mComment.getStatus()) == CommentStatus.APPROVED)) { moderateComment(CommentStatus.APPROVED); } } private void onCommentLiked(OnCommentChanged event) { if (event.isError()) { // Revert button state in case of an error toggleLikeButton(!mBtnLikeComment.isActivated()); } } // OnChanged events @SuppressWarnings("unused") @Subscribe(threadMode = ThreadMode.MAIN) public void onCommentChanged(OnCommentChanged event) { setProgressVisible(false); // Moderating comment if (event.causeOfChange == CommentAction.PUSH_COMMENT) { onCommentModerated(event); mPreviousStatus = null; return; } // New comment (reply) if (event.causeOfChange == CommentAction.CREATE_NEW_COMMENT) { onCommentCreated(event); return; } // Like/Unlike if (event.causeOfChange == CommentAction.LIKE_COMMENT) { onCommentLiked(event); return; } if (event.isError()) { AppLog.i(T.TESTS, "event error type: " + event.error.type + " - message: " + event.error.message); if (isAdded() && !TextUtils.isEmpty(event.error.message)) { ToastUtils.showToast(getActivity(), event.error.message); } return; } if (mCommentIdToFetch != 0) { CommentModel comment = mCommentStore.getCommentBySiteAndRemoteId(mSite, mCommentIdToFetch); setComment(comment, mSite); mCommentIdToFetch = 0; } } }