diff options
Diffstat (limited to 'WordPress/src/main/java/org/wordpress/android/ui/comments')
12 files changed, 4166 insertions, 0 deletions
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentActionResult.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentActionResult.java new file mode 100644 index 000000000..41178da31 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentActionResult.java @@ -0,0 +1,18 @@ +package org.wordpress.android.ui.comments; + +public class CommentActionResult { + + public static final int COMMENT_ID_ON_ERRORS = -1; + public static final int COMMENT_ID_UNKNOWN = -2; // This is used primarily for replies, when the commentID isn't known. + + private long mCommentID = COMMENT_ID_UNKNOWN; + private final String mMessage; + + public CommentActionResult(long commentID, String message) { + mCommentID = commentID; + mMessage = message; + } + + public String getMessage() { return mMessage; } + public boolean isSuccess() { return mCommentID != COMMENT_ID_ON_ERRORS; } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentActions.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentActions.java new file mode 100644 index 000000000..acc83e704 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentActions.java @@ -0,0 +1,503 @@ +package org.wordpress.android.ui.comments; + +import android.os.Handler; +import android.text.TextUtils; + +import com.android.volley.VolleyError; +import com.wordpress.rest.RestRequest; + +import org.json.JSONObject; +import org.wordpress.android.WordPress; +import org.wordpress.android.datasets.CommentTable; +import org.wordpress.android.models.Blog; +import org.wordpress.android.models.Comment; +import org.wordpress.android.models.CommentList; +import org.wordpress.android.models.CommentStatus; +import org.wordpress.android.models.Note; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.VolleyUtils; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlrpc.android.ApiHelper; +import org.xmlrpc.android.ApiHelper.Method; +import org.xmlrpc.android.XMLRPCClientInterface; +import org.xmlrpc.android.XMLRPCException; +import org.xmlrpc.android.XMLRPCFactory; +import org.xmlrpc.android.XMLRPCFault; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * actions related to comments - replies, moderating, etc. + * methods below do network calls in the background & update local DB upon success + * all methods below MUST be called from UI thread + */ + +public class CommentActions { + + private CommentActions() { + throw new AssertionError(); + } + + /* + * listener when a comment action is performed + */ + public interface CommentActionListener { + void onActionResult(CommentActionResult result); + } + + /* + * listener when comments are moderated or deleted + */ + public interface OnCommentsModeratedListener { + void onCommentsModerated(final CommentList moderatedComments); + } + + /* + * used by comment fragments to alert container activity of a change to one or more + * comments (moderated, deleted, added, etc.) + */ + public enum ChangeType {EDITED, REPLIED} + public interface OnCommentChangeListener { + void onCommentChanged(ChangeType changeType); + } + + public interface OnCommentActionListener { + void onModerateComment(int accountId, Comment comment, CommentStatus newStatus); + } + + public interface OnNoteCommentActionListener { + void onModerateCommentForNote(Note note, CommentStatus newStatus); + } + + + /** + * reply to an individual comment + */ + static void submitReplyToComment(final int accountId, + final Comment comment, + final String replyText, + final CommentActionListener actionListener) { + final Blog blog = WordPress.getBlog(accountId); + if (blog==null || comment==null || TextUtils.isEmpty(replyText)) { + if (actionListener != null) { + actionListener.onActionResult(new CommentActionResult(CommentActionResult.COMMENT_ID_ON_ERRORS, null)); + } + return; + } + + final Handler handler = new Handler(); + + new Thread() { + @Override + public void run() { + XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(), + blog.getHttppassword()); + + Map<String, Object> replyHash = new HashMap<>(); + replyHash.put("comment_parent", Long.toString(comment.commentID)); + replyHash.put("content", replyText); + replyHash.put("author", ""); + replyHash.put("author_url", ""); + replyHash.put("author_email", ""); + + Object[] params = { + blog.getRemoteBlogId(), + blog.getUsername(), + blog.getPassword(), + Long.toString(comment.postID), + replyHash }; + + long newCommentID; + String message = null; + try { + Object newCommentIDObject = client.call(Method.NEW_COMMENT, params); + if (newCommentIDObject instanceof Integer) { + newCommentID = ((Integer) newCommentIDObject).longValue(); + } else if (newCommentIDObject instanceof Long) { + newCommentID = (Long) newCommentIDObject; + } else { + AppLog.e(T.COMMENTS, "wp.newComment returned the wrong data type"); + newCommentID = CommentActionResult.COMMENT_ID_ON_ERRORS; + } + } catch (XMLRPCFault e) { + AppLog.e(T.COMMENTS, "Error while sending the new comment", e); + newCommentID = CommentActionResult.COMMENT_ID_ON_ERRORS; + message = e.getFaultString(); + } catch (XMLRPCException | IOException | XmlPullParserException e) { + AppLog.e(T.COMMENTS, "Error while sending the new comment", e); + newCommentID = CommentActionResult.COMMENT_ID_ON_ERRORS; + } + + final CommentActionResult cr = new CommentActionResult(newCommentID, message); + + if (actionListener != null) { + handler.post(new Runnable() { + @Override + public void run() { + actionListener.onActionResult(cr); + } + }); + } + } + }.start(); + } + + /** + * reply to an individual comment that came from a notification - this differs from + * submitReplyToComment() in that it enables responding to a reply to a comment this + * user made on someone else's blog + */ + public static void submitReplyToCommentNote(final Note note, + final String replyText, + final CommentActionListener actionListener) { + if (note == null || TextUtils.isEmpty(replyText)) { + if (actionListener != null) + actionListener.onActionResult(new CommentActionResult(CommentActionResult.COMMENT_ID_ON_ERRORS, null)); + + return; + } + + RestRequest.Listener listener = new RestRequest.Listener() { + @Override + public void onResponse(JSONObject jsonObject) { + if (actionListener != null) + actionListener.onActionResult(new CommentActionResult(CommentActionResult.COMMENT_ID_UNKNOWN, null)); + } + }; + RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + if (volleyError != null) + AppLog.e(T.COMMENTS, volleyError.getMessage(), volleyError); + if (actionListener != null) { + actionListener.onActionResult( + new CommentActionResult(CommentActionResult.COMMENT_ID_ON_ERRORS, VolleyUtils.messageStringFromVolleyError(volleyError)) + ); + } + } + }; + + Note.Reply reply = note.buildReply(replyText); + WordPress.getRestClientUtils().replyToComment(reply.getContent(), reply.getRestPath(), listener, errorListener); + } + + /** + * reply to an individual comment via the WP.com REST API + */ + public static void submitReplyToCommentRestApi(long siteId, long commentId, + final String replyText, + final CommentActionListener actionListener) { + if (TextUtils.isEmpty(replyText)) { + if (actionListener != null) + actionListener.onActionResult(new CommentActionResult(CommentActionResult.COMMENT_ID_ON_ERRORS, null)); + return; + } + + RestRequest.Listener listener = new RestRequest.Listener() { + @Override + public void onResponse(JSONObject jsonObject) { + if (actionListener != null) + actionListener.onActionResult(new CommentActionResult(CommentActionResult.COMMENT_ID_UNKNOWN, null)); + } + }; + RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + if (volleyError != null) + AppLog.e(T.COMMENTS, volleyError.getMessage(), volleyError); + if (actionListener != null) + actionListener.onActionResult( + new CommentActionResult(CommentActionResult.COMMENT_ID_ON_ERRORS, VolleyUtils.messageStringFromVolleyError(volleyError)) + ); + } + }; + + WordPress.getRestClientUtils().replyToComment(siteId, commentId, replyText, listener, errorListener); + } + + /** + * Moderate a comment from a WPCOM notification + */ + public static void moderateCommentRestApi(long siteId, + final long commentId, + CommentStatus newStatus, + final CommentActionListener actionListener) { + + WordPress.getRestClientUtils().moderateComment( + String.valueOf(siteId), + String.valueOf(commentId), + CommentStatus.toRESTString(newStatus), + new RestRequest.Listener() { + @Override + public void onResponse(JSONObject response) { + if (actionListener != null) { + actionListener.onActionResult(new CommentActionResult(commentId, null)); + } + } + }, new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError error) { + if (actionListener != null) { + actionListener.onActionResult(new CommentActionResult(CommentActionResult.COMMENT_ID_ON_ERRORS, null)); + } + } + } + ); + } + + /** + * Moderate a comment from a WPCOM notification + */ + public static void moderateCommentForNote(final Note note, CommentStatus newStatus, + final CommentActionListener actionListener) { + WordPress.getRestClientUtils().moderateComment( + String.valueOf(note.getSiteId()), + String.valueOf(note.getCommentId()), + CommentStatus.toRESTString(newStatus), + new RestRequest.Listener() { + @Override + public void onResponse(JSONObject response) { + if (actionListener != null) { + actionListener.onActionResult(new CommentActionResult(note.getCommentId(), null)); + } + } + }, new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError error) { + if (actionListener != null) { + actionListener.onActionResult(new CommentActionResult(CommentActionResult.COMMENT_ID_ON_ERRORS, null)); + } + } + } + ); + } + + /** + * change the status of a single comment + */ + static void moderateComment(final int accountId, + final Comment comment, + final CommentStatus newStatus, + final CommentActionListener actionListener) { + // deletion is handled separately + if (newStatus != null && (newStatus.equals(CommentStatus.TRASH) || newStatus.equals(CommentStatus.DELETE))) { + deleteComment(accountId, comment, actionListener, newStatus.equals(CommentStatus.DELETE)); + return; + } + + final Blog blog = WordPress.getBlog(accountId); + + if (blog==null || comment==null || newStatus==null || newStatus==CommentStatus.UNKNOWN) { + if (actionListener != null) + actionListener.onActionResult(new CommentActionResult(CommentActionResult.COMMENT_ID_ON_ERRORS, null)); + return; + } + + final Handler handler = new Handler(); + + new Thread() { + @Override + public void run() { + final boolean success = ApiHelper.editComment(blog, comment, newStatus); + + if (success) { + CommentTable.updateCommentStatus(blog.getLocalTableBlogId(), comment.commentID, CommentStatus + .toString(newStatus)); + } + + if (actionListener != null) { + handler.post(new Runnable() { + @Override + public void run() { + actionListener.onActionResult(new CommentActionResult(comment.commentID, null)); + } + }); + } + } + }.start(); + } + + /** + * change the status of multiple comments + * TODO: investigate using system.multiCall to perform a single call to moderate the list + */ + static void moderateComments(final int accountId, + final CommentList comments, + final CommentStatus newStatus, + final OnCommentsModeratedListener actionListener) { + // deletion is handled separately + if (newStatus != null && (newStatus.equals(CommentStatus.TRASH) || newStatus.equals(CommentStatus.DELETE))) { + deleteComments(accountId, comments, actionListener, newStatus.equals(CommentStatus.DELETE)); + return; + } + + final Blog blog = WordPress.getBlog(accountId); + + if (blog==null || comments==null || comments.size() == 0 || newStatus==null || newStatus==CommentStatus.UNKNOWN) { + if (actionListener != null) + actionListener.onCommentsModerated(new CommentList()); + return; + } + + final CommentList moderatedComments = new CommentList(); + final String newStatusStr = CommentStatus.toString(newStatus); + final int localBlogId = blog.getLocalTableBlogId(); + + final Handler handler = new Handler(); + new Thread() { + @Override + public void run() { + for (Comment comment: comments) { + if (ApiHelper.editComment(blog, comment, newStatus)) { + comment.setStatus(newStatusStr); + moderatedComments.add(comment); + } + } + + // update status in SQLite of successfully moderated comments + CommentTable.updateCommentsStatus(localBlogId, moderatedComments, newStatusStr); + + if (actionListener != null) { + handler.post(new Runnable() { + @Override + public void run() { + actionListener.onCommentsModerated(moderatedComments); + } + }); + } + } + }.start(); + } + + /** + * delete (trash) a single comment + */ + private static void deleteComment(final int accountId, + final Comment comment, + final CommentActionListener actionListener, + final boolean deletePermanently) { + final Blog blog = WordPress.getBlog(accountId); + if (blog==null || comment==null) { + if (actionListener != null) + actionListener.onActionResult(new CommentActionResult(CommentActionResult.COMMENT_ID_ON_ERRORS, null)); + return; + } + + final Handler handler = new Handler(); + + new Thread() { + @Override + public void run() { + XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(), + blog.getHttppassword()); + + Object[] params = { + blog.getRemoteBlogId(), + blog.getUsername(), + blog.getPassword(), + comment.commentID, + deletePermanently}; + + Object result; + try { + result = client.call(Method.DELETE_COMMENT, params); + } catch (final XMLRPCException | XmlPullParserException | IOException e) { + AppLog.e(T.COMMENTS, "Error while deleting comment", e); + result = null; + } + + //update local database + final boolean success = (result != null && Boolean.parseBoolean(result.toString())); + if (success){ + if (deletePermanently) { + CommentTable.deleteComment(accountId, comment.commentID); + } + else { + // update status in SQLite of successfully moderated comments + CommentTable.updateCommentStatus(blog.getLocalTableBlogId(), comment.commentID, + CommentStatus.toString(CommentStatus.TRASH)); + } + } + + if (actionListener != null) { + handler.post(new Runnable() { + @Override + public void run() { + actionListener.onActionResult(new CommentActionResult(comment.commentID, null)); + } + }); + } + } + }.start(); + } + + /** + * delete multiple comments + */ + private static void deleteComments(final int accountId, + final CommentList comments, + final OnCommentsModeratedListener actionListener, + final boolean deletePermanently) { + final Blog blog = WordPress.getBlog(accountId); + + if (blog==null || comments==null || comments.size() == 0) { + if (actionListener != null) + actionListener.onCommentsModerated(new CommentList()); + return; + } + + final CommentList deletedComments = new CommentList(); + final int localBlogId = blog.getLocalTableBlogId(); + final int remoteBlogId = blog.getRemoteBlogId(); + + final Handler handler = new Handler(); + new Thread() { + @Override + public void run() { + XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(), + blog.getHttppassword()); + + for (Comment comment: comments) { + Object[] params = { + remoteBlogId, + blog.getUsername(), + blog.getPassword(), + comment.commentID, + deletePermanently}; + + Object result; + try { + result = client.call(Method.DELETE_COMMENT, params); + boolean success = (result != null && Boolean.parseBoolean(result.toString())); + if (success) + deletedComments.add(comment); + } catch (XMLRPCException | XmlPullParserException | IOException e) { + AppLog.e(T.COMMENTS, "Error while deleting comment", e); + } + } + + // remove successfully deleted comments from SQLite + if (deletePermanently) { + CommentTable.deleteComments(localBlogId, deletedComments); + } + else { + // update status in SQLite of successfully moderated comments + CommentTable.updateCommentsStatus(blog.getLocalTableBlogId(), deletedComments, + CommentStatus.toString(CommentStatus.TRASH)); + } + + if (actionListener != null) { + handler.post(new Runnable() { + @Override + public void run() { + actionListener.onCommentsModerated(deletedComments); + } + }); + } + } + }.start(); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentAdapter.java new file mode 100644 index 000000000..d5e5bd6fb --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentAdapter.java @@ -0,0 +1,486 @@ +package org.wordpress.android.ui.comments; + +import android.content.Context; +import android.os.AsyncTask; +import android.support.v4.content.ContextCompat; +import android.support.v7.widget.RecyclerView; +import android.text.Html; +import android.text.Spanned; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import org.wordpress.android.R; +import org.wordpress.android.datasets.CommentTable; +import org.wordpress.android.models.Comment; +import org.wordpress.android.models.CommentList; +import org.wordpress.android.models.CommentStatus; +import org.wordpress.android.util.AniUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.DateTimeUtils; +import org.wordpress.android.util.StringUtils; +import org.wordpress.android.util.WPHtml; +import org.wordpress.android.widgets.WPNetworkImageView; + +import java.util.HashSet; + +class CommentAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { + interface OnDataLoadedListener { + void onDataLoaded(boolean isEmpty); + } + + interface OnLoadMoreListener { + void onLoadMore(); + } + + interface OnSelectedItemsChangeListener { + void onSelectedItemsChanged(); + } + + interface OnCommentPressedListener { + void onCommentPressed(int position, View view); + + void onCommentLongPressed(int position, View view); + } + + private final LayoutInflater mInflater; + private final Context mContext; + + private final CommentList mComments = new CommentList(); + private final HashSet<Long> mSelectedCommentsId = new HashSet<>(); + private final HashSet<Long> mModeratingCommentsIds = new HashSet<>(); + + private final int mStatusColorSpam; + private final int mStatusColorUnapproved; + + private final int mLocalBlogId; + private final int mAvatarSz; + private final String mStatusTextSpam; + private final String mStatusTextUnapproved; + private final int mSelectedColor; + private final int mUnselectedColor; + + private OnDataLoadedListener mOnDataLoadedListener; + private OnCommentPressedListener mOnCommentPressedListener; + private OnLoadMoreListener mOnLoadMoreListener; + private OnSelectedItemsChangeListener mOnSelectedChangeListener; + + private boolean mEnableSelection; + + class CommentHolder extends RecyclerView.ViewHolder + implements View.OnClickListener, View.OnLongClickListener { + private final TextView txtTitle; + private final TextView txtComment; + private final TextView txtStatus; + private final TextView txtDate; + private final WPNetworkImageView imgAvatar; + private final ImageView imgCheckmark; + private final View progressBar; + private final ViewGroup containerView; + + public CommentHolder(View view) { + super(view); + txtTitle = (TextView) view.findViewById(R.id.title); + txtComment = (TextView) view.findViewById(R.id.comment); + txtStatus = (TextView) view.findViewById(R.id.status); + txtDate = (TextView) view.findViewById(R.id.text_date); + imgCheckmark = (ImageView) view.findViewById(R.id.image_checkmark); + imgAvatar = (WPNetworkImageView) view.findViewById(R.id.avatar); + progressBar = view.findViewById(R.id.moderate_progress); + containerView = (ViewGroup) view.findViewById(R.id.layout_container); + + itemView.setOnClickListener(this); + itemView.setOnLongClickListener(this); + } + + @Override + public void onClick(View v) { + if (mOnCommentPressedListener != null) { + mOnCommentPressedListener.onCommentPressed(getAdapterPosition(), v); + } + } + + @Override + public boolean onLongClick(View v) { + if (mOnCommentPressedListener != null) { + mOnCommentPressedListener.onCommentLongPressed(getAdapterPosition(), v); + } + return true; + } + } + + CommentAdapter(Context context, int localBlogId) { + mInflater = LayoutInflater.from(context); + mContext = context; + + mLocalBlogId = localBlogId; + + mStatusColorSpam = ContextCompat.getColor(context, R.color.comment_status_spam); + mStatusColorUnapproved = ContextCompat.getColor(context, R.color.comment_status_unapproved); + + mUnselectedColor = ContextCompat.getColor(context, R.color.white); + mSelectedColor = ContextCompat.getColor(context, R.color.translucent_grey_lighten_20); + + mStatusTextSpam = context.getResources().getString(R.string.comment_status_spam); + mStatusTextUnapproved = context.getResources().getString(R.string.comment_status_unapproved); + + mAvatarSz = context.getResources().getDimensionPixelSize(R.dimen.avatar_sz_medium); + + setHasStableIds(true); + } + + void setOnDataLoadedListener(OnDataLoadedListener listener) { + mOnDataLoadedListener = listener; + } + + void setOnLoadMoreListener(OnLoadMoreListener listener) { + mOnLoadMoreListener = listener; + } + + void setOnCommentPressedListener(OnCommentPressedListener listener) { + mOnCommentPressedListener = listener; + } + + void setOnSelectedItemsChangeListener(OnSelectedItemsChangeListener listener) { + mOnSelectedChangeListener = listener; + } + + @Override + public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view = mInflater.inflate(R.layout.comment_listitem, null); + CommentHolder holder = new CommentHolder(view); + view.setTag(holder); + return holder; + } + + @Override + public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { + Comment comment = mComments.get(position); + CommentHolder holder = (CommentHolder) viewHolder; + + if (isModeratingCommentId(comment.commentID)) { + holder.progressBar.setVisibility(View.VISIBLE); + } else { + holder.progressBar.setVisibility(View.GONE); + } + + holder.txtTitle.setText(Html.fromHtml(comment.getFormattedTitle())); + holder.txtComment.setText(comment.getUnescapedCommentTextWithDrawables()); + holder.txtDate.setText(DateTimeUtils.javaDateToTimeSpan(comment.getDatePublished(), mContext)); + + // status is only shown for comments that haven't been approved + final boolean showStatus; + switch (comment.getStatusEnum()) { + case SPAM: + showStatus = true; + holder.txtStatus.setText(mStatusTextSpam); + holder.txtStatus.setTextColor(mStatusColorSpam); + break; + case UNAPPROVED: + showStatus = true; + holder.txtStatus.setText(mStatusTextUnapproved); + holder.txtStatus.setTextColor(mStatusColorUnapproved); + break; + default: + showStatus = false; + break; + } + holder.txtStatus.setVisibility(showStatus ? View.VISIBLE : View.GONE); + + int checkmarkVisibility; + if (mEnableSelection && isItemSelected(position)) { + checkmarkVisibility = View.VISIBLE; + holder.containerView.setBackgroundColor(mSelectedColor); + } else { + checkmarkVisibility = View.GONE; + holder.imgAvatar.setImageUrl(comment.getAvatarForDisplay(mAvatarSz), WPNetworkImageView.ImageType.AVATAR); + holder.containerView.setBackgroundColor(mUnselectedColor); + } + + if (holder.imgCheckmark.getVisibility() != checkmarkVisibility) { + holder.imgCheckmark.setVisibility(checkmarkVisibility); + } + + // comment text needs to be to the left of date/status when the title is a single line and + // the status is displayed or else the status may overlap the comment text - note that + // getLineCount() will return 0 if the view hasn't been rendered yet, which is why we + // check getLineCount() <= 1 + boolean adjustComment = (showStatus && holder.txtTitle.getLineCount() <= 1); + RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) holder.txtComment.getLayoutParams(); + if (adjustComment) { + params.addRule(RelativeLayout.LEFT_OF, R.id.layout_date_status); + } else { + params.addRule(RelativeLayout.LEFT_OF, 0); + } + + // request to load more comments when we near the end + if (mOnLoadMoreListener != null && position >= getItemCount() - 1 + && position >= CommentsListFragment.COMMENTS_PER_PAGE - 1) { + mOnLoadMoreListener.onLoadMore(); + } + } + + public Comment getItem(int position) { + if (isPositionValid(position)) { + return mComments.get(position); + } else { + return null; + } + } + + @Override + public long getItemId(int position) { + return mComments.get(position).commentID; + } + + @Override + public int getItemCount() { + return mComments.size(); + } + + private boolean isEmpty() { + return getItemCount() == 0; + } + + void setEnableSelection(boolean enable) { + if (enable == mEnableSelection) return; + + mEnableSelection = enable; + if (mEnableSelection) { + notifyDataSetChanged(); + } else { + clearSelectedComments(); + } + } + + void clearSelectedComments() { + if (mSelectedCommentsId.size() > 0) { + mSelectedCommentsId.clear(); + notifyDataSetChanged(); + if (mOnSelectedChangeListener != null) { + mOnSelectedChangeListener.onSelectedItemsChanged(); + } + } + } + + int getSelectedCommentCount() { + return mSelectedCommentsId.size(); + } + + CommentList getSelectedComments() { + CommentList comments = new CommentList(); + if (!mEnableSelection) { + return comments; + } + + for (Long commentId : mSelectedCommentsId) { + int commentIndex = indexOfCommentId(commentId); + if (commentIndex > -1) { + comments.add(mComments.get(commentIndex)); + } + } + + return comments; + } + + private boolean isItemSelected(int position) { + Comment comment = getItem(position); + return comment != null && mSelectedCommentsId.contains(comment.commentID); + } + + void setItemSelected(int position, boolean isSelected, View view) { + if (isItemSelected(position) == isSelected) return; + + Comment comment = getItem(position); + if (comment == null) return; + + if (isSelected) { + mSelectedCommentsId.add(comment.commentID); + } else { + mSelectedCommentsId.remove(comment.commentID); + } + + + notifyItemChanged(position); + + if (view != null && view.getTag() instanceof CommentHolder) { + CommentHolder holder = (CommentHolder) view.getTag(); + // animate the selection change + AniUtils.startAnimation(holder.imgCheckmark, isSelected ? R.anim.cab_select : R.anim.cab_deselect); + holder.imgCheckmark.setVisibility(isSelected ? View.VISIBLE : View.GONE); + } + + if (mOnSelectedChangeListener != null) { + mOnSelectedChangeListener.onSelectedItemsChanged(); + } + } + + void toggleItemSelected(int position, View view) { + setItemSelected(position, !isItemSelected(position), view); + } + + public void addModeratingCommentId(long commentId) { + mModeratingCommentsIds.add(commentId); + int position = indexOfCommentId(commentId); + if (position >= 0) { + notifyItemChanged(position); + } + } + + public void removeModeratingCommentId(long commentId) { + mModeratingCommentsIds.remove(commentId); + int position = indexOfCommentId(commentId); + if (position >= 0) { + notifyItemChanged(position); + } + } + + public boolean isModeratingCommentId(long commentId) { + return mModeratingCommentsIds.size() > 0 + && mModeratingCommentsIds.contains(commentId); + } + + private int indexOfCommentId(long commentId) { + return mComments.indexOfCommentId(commentId); + } + + private boolean isPositionValid(int position) { + return (position >= 0 && position < mComments.size()); + } + + void replaceComments(CommentList comments) { + mComments.replaceComments(comments); + notifyDataSetChanged(); + } + + void deleteComments(CommentList comments) { + mComments.deleteComments(comments); + notifyDataSetChanged(); + if (mOnDataLoadedListener != null) { + mOnDataLoadedListener.onDataLoaded(isEmpty()); + } + } + + public void removeComment(Comment comment) { + int position = indexOfCommentId(comment.commentID); + if (position >= 0) { + mComments.remove(position); + notifyItemRemoved(position); + } + } + + /* + * clear all comments + */ + void clearComments() { + if (mComments != null) { + mComments.clear(); + notifyDataSetChanged(); + } + } + + /* + * load comments using an AsyncTask + */ + void loadComments(CommentStatus statusFilter) { + if (mIsLoadTaskRunning) { + AppLog.w(AppLog.T.COMMENTS, "load comments task already active"); + } else { + new LoadCommentsTask(statusFilter).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + } + + /* + * AsyncTask to load comments from SQLite + */ + private boolean mIsLoadTaskRunning = false; + + private class LoadCommentsTask extends AsyncTask<Void, Void, Boolean> { + CommentList tmpComments; + final CommentStatus mStatusFilter; + + public LoadCommentsTask(CommentStatus statusFilter) { + mStatusFilter = statusFilter; + } + + @Override + protected void onPreExecute() { + mIsLoadTaskRunning = true; + } + + @Override + protected void onCancelled() { + mIsLoadTaskRunning = false; + } + + @Override + protected Boolean doInBackground(Void... params) { + if (mStatusFilter == null) { + tmpComments = CommentTable.getCommentsForBlogWithFilter(mLocalBlogId, CommentStatus.UNKNOWN); + } else { + tmpComments = CommentTable.getCommentsForBlogWithFilter(mLocalBlogId, mStatusFilter); + } + + if (mComments.isSameList(tmpComments)) { + return false; + } + + // pre-calc transient values so they're cached prior to display + for (Comment comment : tmpComments) { + comment.getDatePublished(); + comment.getUnescapedPostTitle(); + comment.getAvatarForDisplay(mAvatarSz); + comment.getFormattedTitle(); + + String content = StringUtils.notNullStr(comment.getCommentText()); + //to load images embedded within comments, pass an ImageGetter to WPHtml.fromHtml() + Spanned spanned = WPHtml.fromHtml(content, null, null, mContext, null, 0); + comment.setUnescapedCommentWithDrawables(spanned); + } + + return true; + } + + @Override + protected void onPostExecute(Boolean result) { + if (result) { + mComments.clear(); + mComments.addAll(tmpComments); + notifyDataSetChanged(); + } + + if (mOnDataLoadedListener != null) { + mOnDataLoadedListener.onDataLoaded(isEmpty()); + } + + mIsLoadTaskRunning = false; + } + } + + public HashSet<Long> getSelectedCommentsId() { + return mSelectedCommentsId; + } + + + public CommentAdapterState getAdapterState() { + return new CommentAdapterState(mSelectedCommentsId, mModeratingCommentsIds); + } + + public void setInitialState(CommentAdapterState adapterState) { + if (adapterState == null) return; + + if (adapterState.hasSelectedComments()) { + mSelectedCommentsId.clear(); + mSelectedCommentsId.addAll(adapterState.getSelectedComments()); + setEnableSelection(true); + } + + if (adapterState.hasModeratingComments()) { + mModeratingCommentsIds.clear(); + mModeratingCommentsIds.addAll(adapterState.getModeratedCommentsId()); + } + } +}
\ No newline at end of file diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentAdapterState.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentAdapterState.java new file mode 100644 index 000000000..6d9ab1530 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentAdapterState.java @@ -0,0 +1,69 @@ +package org.wordpress.android.ui.comments; + +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.NonNull; + +import java.util.HashSet; + +/** + * Used to store state of {@link CommentAdapter} + */ +public class CommentAdapterState implements Parcelable { + public static final String KEY = "comments_adapter_state"; + + private final HashSet<Long> mSelectedComments; + private final HashSet<Long> mModeratedCommentsId; + + public CommentAdapterState(@NonNull HashSet<Long> selectedComments, @NonNull HashSet<Long> moderatedCommentsId) { + mSelectedComments = selectedComments; + mModeratedCommentsId = moderatedCommentsId; + } + + public HashSet<Long> getSelectedComments() { + return mSelectedComments; + } + + public HashSet<Long> getModeratedCommentsId() { + return mModeratedCommentsId; + } + + + public boolean hasSelectedComments() { + return mSelectedComments != null && mSelectedComments.size() > 0; + } + + public boolean hasModeratingComments() { + return mModeratedCommentsId != null && mModeratedCommentsId.size() > 0; + } + + @SuppressWarnings("unchecked") + private CommentAdapterState(Parcel in) { + mSelectedComments = (HashSet<Long>) in.readSerializable(); + mModeratedCommentsId = (HashSet<Long>) in.readSerializable(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeSerializable(mSelectedComments); + dest.writeSerializable(mModeratedCommentsId); + } + + @SuppressWarnings("unused") + public static final Parcelable.Creator<CommentAdapterState> CREATOR = new Parcelable.Creator<CommentAdapterState>() { + @Override + public CommentAdapterState createFromParcel(Parcel in) { + return new CommentAdapterState(in); + } + + @Override + public CommentAdapterState[] newArray(int size) { + return new CommentAdapterState[size]; + } + }; +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailActivity.java new file mode 100644 index 000000000..69e8938e1 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailActivity.java @@ -0,0 +1,92 @@ +package org.wordpress.android.ui.comments; + +import android.content.Intent; +import android.os.Bundle; +import android.support.v7.app.AppCompatActivity; +import android.view.MenuItem; + +import com.simperium.client.BucketObjectMissingException; + +import org.wordpress.android.R; +import org.wordpress.android.models.Note; +import org.wordpress.android.ui.ActivityId; +import org.wordpress.android.ui.notifications.utils.SimperiumUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.ToastUtils; + +// simple wrapper activity for CommentDetailFragment +public class CommentDetailActivity extends AppCompatActivity { + + private static final String KEY_COMMENT_DETAIL_LOCAL_TABLE_BLOG_ID = "local_table_blog_id"; + private static final String KEY_COMMENT_DETAIL_COMMENT_ID = "comment_detail_comment_id"; + private static final String KEY_COMMENT_DETAIL_NOTE_ID = "comment_detail_note_id"; + private static final String KEY_COMMENT_DETAIL_IS_REMOTE = "comment_detail_is_remote"; + + private static final String TAG_COMMENT_DETAIL_FRAGMENT = "tag_comment_detail_fragment"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + + super.onCreate(savedInstanceState); + + setContentView(R.layout.comment_activity_detail); + + setTitle(R.string.comment); + + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + if (savedInstanceState == null) { + Intent intent = getIntent(); + CommentDetailFragment commentDetailFragment = null; + if (intent.getStringExtra(KEY_COMMENT_DETAIL_NOTE_ID) != null && SimperiumUtils.getNotesBucket() != null) { + try { + Note note = SimperiumUtils.getNotesBucket().get( + intent.getStringExtra(KEY_COMMENT_DETAIL_NOTE_ID) + ); + + if (intent.hasExtra(KEY_COMMENT_DETAIL_IS_REMOTE)) { + commentDetailFragment = CommentDetailFragment.newInstanceForRemoteNoteComment(note.getId()); + } else { + commentDetailFragment = CommentDetailFragment.newInstance(note.getId()); + } + } catch (BucketObjectMissingException e) { + AppLog.e(AppLog.T.NOTIFS, "CommentDetailActivity was passed an invalid note id."); + } + } else if (intent.getIntExtra(KEY_COMMENT_DETAIL_LOCAL_TABLE_BLOG_ID, 0) > 0 + && intent.getLongExtra(KEY_COMMENT_DETAIL_COMMENT_ID, 0) > 0) { + commentDetailFragment = CommentDetailFragment.newInstance( + intent.getIntExtra(KEY_COMMENT_DETAIL_LOCAL_TABLE_BLOG_ID, 0), + intent.getLongExtra(KEY_COMMENT_DETAIL_COMMENT_ID, 0) + ); + } + + if (commentDetailFragment != null) { + commentDetailFragment.setRetainInstance(true); + getFragmentManager().beginTransaction() + .add(R.id.comment_detail_container, commentDetailFragment, TAG_COMMENT_DETAIL_FRAGMENT) + .commit(); + } else { + ToastUtils.showToast(this, R.string.error_load_comment); + finish(); + } + } + } + + @Override + public void onResume() { + super.onResume(); + ActivityId.trackLastActivity(ActivityId.COMMENT_DETAIL); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + + return super.onOptionsItemSelected(item); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailFragment.java new file mode 100644 index 000000000..eafe02723 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailFragment.java @@ -0,0 +1,1234 @@ +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.v4.content.ContextCompat; +import android.text.Html; +import android.text.TextUtils; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +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 com.android.volley.VolleyError; +import com.simperium.client.BucketObjectMissingException; +import com.wordpress.rest.RestRequest; + +import org.apache.commons.lang.StringEscapeUtils; +import org.json.JSONObject; +import org.wordpress.android.Constants; +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.CommentTable; +import org.wordpress.android.datasets.ReaderPostTable; +import org.wordpress.android.datasets.SuggestionTable; +import org.wordpress.android.models.AccountHelper; +import org.wordpress.android.models.Comment; +import org.wordpress.android.models.CommentStatus; +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.notifications.utils.SimperiumUtils; +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.LanguageUtils; +import org.wordpress.android.util.NetworkUtils; +import org.wordpress.android.util.ToastUtils; +import org.wordpress.android.util.VolleyUtils; +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 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_LOCAL_BLOG_ID = "local_blog_id"; + private static final String KEY_COMMENT_ID = "comment_id"; + private static final String KEY_NOTE_ID = "note_id"; + private int mLocalBlogId; + private int mRemoteBlogId; + private Comment mComment; + private Note mNote; + 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 View mBtnLikeComment; + private ImageView mBtnLikeIcon; + private TextView mBtnLikeTextView; + private View mBtnModerateComment; + private ImageView mBtnModerateIcon; + private TextView mBtnModerateTextView; + private TextView mBtnSpamComment; + private TextView mBtnTrashComment; + private String mRestoredReplyText; + private String mRestoredNoteId; + private boolean mIsUsersBlog = false; + private boolean mShouldFocusReplyField; + private boolean mShouldLikeInstantly; + private boolean mShouldApproveInstantly; + + /* + * Used to request a comment from a note using its site and comment ids, rather than build + * the comment with the content in the note. See showComment() + */ + private boolean mShouldRequestCommentFromNote = false; + 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(int localBlogId, long commentId) { + CommentDetailFragment fragment = new CommentDetailFragment(); + fragment.setComment(localBlogId, commentId); + return fragment; + } + + /* + * used when called from notification list for a comment notification + */ + public static CommentDetailFragment newInstance(final String noteId) { + CommentDetailFragment fragment = new CommentDetailFragment(); + fragment.setNoteWithNoteId(noteId); + return fragment; + } + + /* + * used when called from a comment notification 'like' action + */ + public static CommentDetailFragment newInstanceForInstantLike(final String noteId) { + CommentDetailFragment fragment = newInstance(noteId); + //here tell the fragment to trigger the Like action when ready + fragment.setLikeCommentWhenReady(); + return fragment; + } + + /* + * used when called from a comment notification 'approve' action + */ + public static CommentDetailFragment newInstanceForInstantApprove(final String noteId) { + CommentDetailFragment fragment = newInstance(noteId); + //here tell the fragment to trigger the Like action when ready + fragment.setApproveCommentWhenReady(); + return fragment; + } + + /* + * used when called from notifications to load a comment that doesn't already exist in the note + */ + public static CommentDetailFragment newInstanceForRemoteNoteComment(final String noteId) { + CommentDetailFragment fragment = newInstance(noteId); + fragment.enableShouldRequestCommentFromNote(); + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (savedInstanceState != null) { + if (savedInstanceState.getString(KEY_NOTE_ID) != null) { + // The note will be set in onResume() because Simperium will be running there + // See WordPress.deferredInit() + mRestoredNoteId = savedInstanceState.getString(KEY_NOTE_ID); + } else { + int localBlogId = savedInstanceState.getInt(KEY_LOCAL_BLOG_ID); + long commentId = savedInstanceState.getLong(KEY_COMMENT_ID); + setComment(localBlogId, commentId); + } + } + + setHasOptionsMenu(true); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if (hasComment()) { + outState.putInt(KEY_LOCAL_BLOG_ID, getLocalBlogId()); + outState.putLong(KEY_COMMENT_ID, getCommentId()); + } + + if (mNote != null) { + outState.putString(KEY_NOTE_ID, mNote.getId()); + } + } + + @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); + mBtnSpamComment = (TextView) mLayoutButtons.findViewById(R.id.text_btn_spam); + mBtnTrashComment = (TextView) mLayoutButtons.findViewById(R.id.image_trash_comment); + + setTextDrawable(mBtnSpamComment, R.drawable.ic_action_spam); + setTextDrawable(mBtnTrashComment, R.drawable.ic_action_trash); + + mLayoutReply = (ViewGroup) view.findViewById(R.id.layout_comment_box); + mEditReply = (SuggestionAutoCompleteText) mLayoutReply.findViewById(R.id.edit_comment); + mEditReply.getAutoSaveTextHelper().setUniqueId(String.format(LanguageUtils.getCurrentDeviceLanguage(getActivity()), "%s%d%d", + AccountHelper.getCurrentUsernameForBlog(WordPress.getCurrentBlog()), + getRemoteBlogId(), getCommentId())); + + mSubmitReplyBtn = mLayoutReply.findViewById(R.id.btn_submit_reply); + + View replyBox = mLayoutReply.findViewById(R.id.reply_box); + if (mComment != null && + (mComment.getStatusEnum() == CommentStatus.SPAM || + mComment.getStatusEnum() == CommentStatus.TRASH || + mComment.getStatusEnum() == CommentStatus.DELETE)) { + replyBox.setVisibility(View.GONE); + } else { + replyBox.setVisibility(View.VISIBLE); + } + + // hide comment like button until we know it can be enabled in showCommentForNote() + 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 (!hasComment()) return; + + if (mComment.getStatusEnum() == CommentStatus.SPAM) { + moderateComment(CommentStatus.APPROVED); + } else { + moderateComment(CommentStatus.SPAM); + } + } + }); + + mBtnTrashComment.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (!hasComment()) return; + + if (mComment.willTrashingPermanentlyDelete()) { + 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.DELETE); + } + }); + 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); + } + }); + + 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)) { + setNoteWithNoteId(mRestoredNoteId); + mRestoredNoteId = null; + } + + if (mShouldLikeInstantly) { + mShouldLikeInstantly = false; + likeComment(true); + } else if (mShouldApproveInstantly) { + mShouldApproveInstantly = false; + performModerateAction(); + } + + } + + private void setupSuggestionServiceAndAdapter() { + if (!isAdded()) return; + + mSuggestionServiceConnectionManager = new SuggestionServiceConnectionManager(getActivity(), mRemoteBlogId); + mSuggestionAdapter = SuggestionUtils.setupSuggestions(mRemoteBlogId, getActivity(), mSuggestionServiceConnectionManager); + if (mSuggestionAdapter != null) { + mEditReply.setAdapter(mSuggestionAdapter); + } + } + + private void setComment(int localBlogId, long commentId) { + setComment(localBlogId, CommentTable.getComment(localBlogId, commentId)); + } + + private void setComment(int localBlogId, final Comment comment) { + mComment = comment; + mLocalBlogId = localBlogId; + + // 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 && WordPress.wpDB.isLocalBlogIdInDatabase(mLocalBlogId)); + + if (mIsUsersBlog) + mRemoteBlogId = WordPress.wpDB.getRemoteBlogIdForLocalTableBlogId(mLocalBlogId); + + if (isAdded()) + showComment(); + } + + private void disableShouldFocusReplyField() { + mShouldFocusReplyField = false; + } + + private void enableShouldRequestCommentFromNote() { + mShouldRequestCommentFromNote = true; + } + + @Override + public Note getNote() { + return mNote; + } + + @Override + public void setNote(Note note) { + mNote = note; + if (isAdded() && mNote != null) { + showComment(); + } + } + + private void setNoteWithNoteId(String noteId) { + if (noteId == null) return; + + if (SimperiumUtils.getNotesBucket() != null) { + try { + Note note = SimperiumUtils.getNotesBucket().get(noteId); + setNote(note); + setRemoteBlogId(note.getSiteId()); + } catch (BucketObjectMissingException e) { + e.printStackTrace(); + } + } + } + + @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); + showComment(); + } + + @Override + public void onStop() { + EventBus.getDefault().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 && event.mRemoteBlogId == mRemoteBlogId && mSuggestionAdapter != null) { + List<Suggestion> suggestions = SuggestionTable.getSuggestionsForSite(event.mRemoteBlogId); + mSuggestionAdapter.setSuggestionList(suggestions); + } + } + + @Override + public void onPause() { + super.onPause(); + // Reset comment if this is from a notification + if (mNote != null) { + mComment = null; + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == Constants.INTENT_COMMENT_EDITOR && resultCode == Activity.RESULT_OK) { + if (mNote == null) { + reloadComment(); + } + // tell the host to reload the comment list + if (mOnCommentChangeListener != null) + mOnCommentChangeListener.onCommentChanged(ChangeType.EDITED); + } + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + menu.clear(); + inflater.inflate(R.menu.comment_detail, menu); + if (!canEdit()) { + menu.removeItem(R.id.menu_edit_comment); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + if (item.getItemId() == R.id.menu_edit_comment) { + editComment(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + private boolean hasComment() { + return (mComment != null); + } + + private long getCommentId() { + return (mComment != null ? mComment.commentID : 0); + } + + private int getLocalBlogId() { + return mLocalBlogId; + } + + private int getRemoteBlogId() { + return mRemoteBlogId; + } + + private void setRemoteBlogId(int remoteBlogId) { + mRemoteBlogId = remoteBlogId; + } + + /* + * reload the current comment from the local database + */ + private void reloadComment() { + if (!hasComment()) + return; + Comment updatedComment = CommentTable.getComment(mLocalBlogId, getCommentId()); + setComment(mLocalBlogId, updatedComment); + } + + /* + * open the comment for editing + */ + private void editComment() { + if (!isAdded() || !hasComment()) + 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(EditCommentActivity.ARG_LOCAL_BLOG_ID, getLocalBlogId()); + intent.putExtra(EditCommentActivity.ARG_COMMENT_ID, getCommentId()); + if (mNote != null) { + intent.putExtra(EditCommentActivity.ARG_NOTE_ID, mNote.getId()); + } + startActivityForResult(intent, Constants.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 && mShouldRequestCommentFromNote) { + // If a remote comment was requested, check if we have the comment for display. + // Otherwise request the comment via the REST API + int localTableBlogId = WordPress.wpDB.getLocalTableBlogIdForRemoteBlogId(mNote.getSiteId()); + if (localTableBlogId > 0) { + Comment comment = CommentTable.getComment(localTableBlogId, mNote.getParentCommentId()); + if (comment != null) { + setComment(localTableBlogId, comment); + return; + } + } + + long commentId = mNote.getParentCommentId() > 0 ? mNote.getParentCommentId() : mNote.getCommentId(); + requestComment(localTableBlogId, mNote.getSiteId(), commentId); + } else if (mNote != null) { + showCommentForNote(mNote); + } + + return; + } + + scrollView.setVisibility(View.VISIBLE); + layoutBottom.setVisibility(View.VISIBLE); + + // Add action buttons footer + if ((mNote == null || mShouldRequestCommentFromNote) && mLayoutButtons.getParent() == null) { + ViewGroup commentContentLayout = (ViewGroup) getView().findViewById(R.id.comment_content_container); + commentContentLayout.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.hasAuthorName() ? HtmlUtils.fastUnescapeHtml(mComment.getAuthorName()) : getString(R.string.anonymous)); + txtDate.setText(DateTimeUtils.javaDateToTimeSpan(mComment.getDatePublished(), WordPress.getContext())); + + int maxImageSz = getResources().getDimensionPixelSize(R.dimen.reader_comment_max_image_size); + CommentUtils.displayHtmlComment(mTxtContent, mComment.getCommentText(), maxImageSz); + + int avatarSz = getResources().getDimensionPixelSize(R.dimen.avatar_sz_large); + if (mComment.hasProfileImageUrl()) { + imgAvatar.setImageUrl(GravatarUtils.fixGravatarUrl(mComment.getProfileImageUrl(), avatarSz), WPNetworkImageView.ImageType.AVATAR); + } else if (mComment.hasAuthorEmail()) { + 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.hasAuthorUrl()) { + 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(getRemoteBlogId(), mComment.postID); + + // make sure reply box is showing + if (mLayoutReply.getVisibility() != View.VISIBLE && canReply()) { + AniUtils.animateBottomBar(mLayoutReply, true); + if (mEditReply != null && mShouldFocusReplyField) { + mEditReply.requestFocus(); + 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 (hasComment() && !mComment.hasPostTitle()) { + mComment.setPostTitle(postTitle); + CommentTable.updateCommentPostTitle(getLocalBlogId(), getCommentId(), postTitle); + } + + // 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 int blogId, final long postId) { + if (!isAdded()) + return; + + final TextView txtPostTitle = (TextView) getView().findViewById(R.id.text_post_title); + boolean postExists = ReaderPostTable.postExists(blogId, 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 isDotComOrJetpack = WordPress.wpDB.isRemoteBlogIdDotComOrJetpack(mRemoteBlogId); + boolean canRequestPost = isDotComOrJetpack && AccountHelper.isSignedInWordPressDotCom(); + + final String title; + final boolean hasTitle; + if (mComment.hasPostTitle()) { + // 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(blogId, 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.requestPost(blogId, 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(blogId, 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(), mRemoteBlogId, (int) mComment.postID); + } else { + // right now this will happen from notifications + AppLog.i(T.COMMENTS, "comment detail > no post click listener"); + ReaderActivityLauncher.showReaderPostDetail(getActivity(), mRemoteBlogId, mComment.postID); + } + } + }); + } + } + + 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(final CommentStatus newStatus) { + if (!isAdded() || !hasComment()) + return; + if (!NetworkUtils.checkConnection(getActivity())) + return; + + // Fire the appropriate listener if we have one + if (mNote != null && mOnNoteCommentActionListener != null) { + mOnNoteCommentActionListener.onModerateCommentForNote(mNote, newStatus); + trackModerationFromNotification(newStatus); + return; + } else if (mOnCommentActionListener != null) { + mOnCommentActionListener.onModerateComment(mLocalBlogId, mComment, newStatus); + return; + } + + if (mNote == null) return; + + // Basic moderation support, currently only used when this Fragment is in a CommentDetailActivity + // Uses WP.com REST API and requires a note object + final CommentStatus oldStatus = mComment.getStatusEnum(); + mComment.setStatus(CommentStatus.toString(newStatus)); + updateStatusViews(); + CommentActions.moderateCommentRestApi(mNote.getSiteId(), mComment.commentID, newStatus, new CommentActions.CommentActionListener() { + @Override + public void onActionResult(CommentActionResult result) { + if (!isAdded()) return; + + if (result.isSuccess()) { + ToastUtils.showToast(getActivity(), R.string.comment_moderated_approved, ToastUtils.Duration.SHORT); + } else { + mComment.setStatus(CommentStatus.toString(oldStatus)); + updateStatusViews(); + ToastUtils.showToast(getActivity(), R.string.error_moderate_comment); + } + } + }); + } + + /* + * post comment box text as a reply to the current comment + */ + private void submitReply() { + if (!hasComment() || !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); + + CommentActions.CommentActionListener actionListener = new CommentActions.CommentActionListener() { + @Override + public void onActionResult(CommentActionResult result) { + mIsSubmittingReply = false; + if (result.isSuccess() && mOnCommentChangeListener != null) + mOnCommentChangeListener.onCommentChanged(ChangeType.REPLIED); + if (isAdded()) { + mEditReply.setEnabled(true); + mSubmitReplyBtn.setVisibility(View.VISIBLE); + progress.setVisibility(View.GONE); + updateStatusViews(); + if (result.isSuccess()) { + ToastUtils.showToast(getActivity(), getString(R.string.note_reply_successful)); + mEditReply.setText(null); + mEditReply.getAutoSaveTextHelper().clearSavedText(mEditReply); + + // approve the comment + if (mComment != null && mComment.getStatusEnum() != CommentStatus.APPROVED) { + moderateComment(CommentStatus.APPROVED); + } + } else { + String errorMessage = TextUtils.isEmpty(result.getMessage()) ? getString(R.string.reply_failed) : result.getMessage(); + String strUnEscapeHTML = StringEscapeUtils.unescapeHtml(errorMessage); + ToastUtils.showToast(getActivity(), strUnEscapeHTML, ToastUtils.Duration.LONG); + // refocus editor on failure and show soft keyboard + EditTextUtils.showSoftInput(mEditReply); + } + } + } + }; + + mIsSubmittingReply = true; + + AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATION_REPLIED_TO); + if (mNote != null) { + if (mShouldRequestCommentFromNote) { + CommentActions.submitReplyToCommentRestApi(mNote.getSiteId(), mComment.commentID, replyText, actionListener); + } else { + CommentActions.submitReplyToCommentNote(mNote, replyText, actionListener); + } + } else { + CommentActions.submitReplyToComment(mLocalBlogId, mComment, replyText, actionListener); + } + } + + /* + * sets the drawable for moderation buttons + */ + private void setTextDrawable(final TextView view, int resId) { + view.setCompoundDrawablesWithIntrinsicBounds(null, ContextCompat.getDrawable(getActivity(), resId), null, null); + } + + /* + * 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() || !hasComment()) + return; + + final int statusTextResId; // string resource id for status text + final int statusColor; // color for status text + + switch (mComment.getStatusEnum()) { + 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 (mNote != null && canLike()) { + mBtnLikeComment.setVisibility(View.VISIBLE); + + toggleLikeButton(mNote.hasLikedComment()); + } else { + mBtnLikeComment.setVisibility(View.GONE); + } + + // comment status is only shown if this comment is from one of this user's blogs and the + // comment hasn't been approved + if (mIsUsersBlog && mComment.getStatusEnum() != 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(mComment.getStatusEnum()); + 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 (mComment.getStatusEnum() == CommentStatus.SPAM) { + mBtnSpamComment.setText(R.string.mnu_comment_unspam); + } else { + mBtnSpamComment.setText(R.string.mnu_comment_spam); + } + } else { + mBtnSpamComment.setVisibility(View.GONE); + } + + if (canTrash()) { + mBtnTrashComment.setVisibility(View.VISIBLE); + if (mComment.getStatusEnum() == CommentStatus.TRASH) { + mBtnModerateIcon.setImageResource(R.drawable.ic_action_restore); + //mBtnModerateTextView.setTextColor(getActivity().getResources().getColor(R.color.notification_status_unapproved_dark)); + mBtnModerateTextView.setText(R.string.mnu_comment_untrash); + mBtnTrashComment.setText(R.string.mnu_comment_delete_permanently); + } else { + mBtnTrashComment.setText(R.string.mnu_comment_trash); + } + } else { + mBtnTrashComment.setVisibility(View.GONE); + } + + mLayoutButtons.setVisibility(View.VISIBLE); + } + + private void performModerateAction(){ + if (!hasComment() || !isAdded() || !NetworkUtils.checkConnection(getActivity())) { + return; + } + + CommentStatus newStatus = CommentStatus.APPROVED; + if (mComment.getStatusEnum() == 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_action_approve_active); + mBtnModerateTextView.setText(R.string.comment_status_approved); + mBtnModerateTextView.setTextColor(ContextCompat.getColor(getActivity(), R.color.notification_status_unapproved_dark)); + } else { + mBtnModerateIcon.setImageResource(R.drawable.ic_action_approve); + 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 (mLocalBlogId > 0 && canModerate()); + } + + private boolean canLike() { + return (!mShouldRequestCommentFromNote && mEnabledActions != null && mEnabledActions.contains(EnabledActions.ACTION_LIKE)); + } + + /* + * display the comment associated with the passed notification + */ + private void showCommentForNote(Note note) { + 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); + } + + // Now we'll add a detail fragment list + FragmentManager fragmentManager = getFragmentManager(); + FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); + mNotificationsDetailListFragment = NotificationsDetailListFragment.newInstance(note.getId()); + mNotificationsDetailListFragment.setFooterView(mLayoutButtons); + // Listen for note changes from the detail list fragment, so we can update the status buttons + mNotificationsDetailListFragment.setOnNoteChangeListener(new NotificationsDetailListFragment.OnNoteChangeListener() { + @Override + public void onNoteChanged(Note note) { + mNote = note; + mComment = mNote.buildComment(); + updateStatusViews(); + } + }); + fragmentTransaction.replace(R.id.comment_content_container, mNotificationsDetailListFragment); + fragmentTransaction.commitAllowingStateLoss(); + + /* + * 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())); + } + + // note that the local blog id won't be found if the comment is from someone else's blog + int localBlogId = WordPress.wpDB.getLocalTableBlogIdForRemoteBlogId(mRemoteBlogId); + + setComment(localBlogId, note.buildComment()); + + getFragmentManager().invalidateOptionsMenu(); + } + + private void setLikeCommentWhenReady() { + mShouldLikeInstantly = true; + } + + private void setApproveCommentWhenReady() { + mShouldApproveInstantly = true; + } + + // Like or unlike a comment via the REST API + private void likeComment(boolean forceLike) { + if (mNote == null) return; + 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); + + boolean commentWasUnapproved = false; + 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() && mComment.getStatusEnum() == CommentStatus.UNAPPROVED) { + mComment.setStatus(CommentStatus.toString(CommentStatus.APPROVED)); + mNotificationsDetailListFragment.refreshBlocksForCommentStatus(CommentStatus.APPROVED); + setModerateButtonForStatus(CommentStatus.APPROVED); + commentWasUnapproved = true; + } + } + + final boolean commentStatusShouldRevert = commentWasUnapproved; + WordPress.getRestClientUtils().likeComment(String.valueOf(mNote.getSiteId()), + String.valueOf(mNote.getCommentId()), + mBtnLikeComment.isActivated(), + new RestRequest.Listener() { + @Override + public void onResponse(JSONObject response) { + if (response != null && !response.optBoolean("success")) { + if (!isAdded()) return; + + // Failed, so switch the button state back + toggleLikeButton(!mBtnLikeComment.isActivated()); + + if (commentStatusShouldRevert) { + setCommentStatusUnapproved(); + } + } + } + }, new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError error) { + if (!isAdded()) return; + + toggleLikeButton(!mBtnLikeComment.isActivated()); + + if (commentStatusShouldRevert) { + setCommentStatusUnapproved(); + } + } + }); + } + + private void setCommentStatusUnapproved() { + mComment.setStatus(CommentStatus.toString(CommentStatus.UNAPPROVED)); + mNotificationsDetailListFragment.refreshBlocksForCommentStatus(CommentStatus.UNAPPROVED); + setModerateButtonForStatus(CommentStatus.UNAPPROVED); + } + + 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_action_like_active)); + 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_action_like)); + mBtnLikeComment.setActivated(false); + } + } + + /* + * request a comment - note that this uses the REST API rather than XMLRPC, which means the user must + * either be wp.com or have Jetpack, but it's safe to do this since this method is only called when + * displayed from a notification (and notifications require wp.com/Jetpack) + */ + private void requestComment(final int localBlogId, + final int remoteBlogId, + final long commentId) { + + final ProgressBar progress = (isAdded() && getView() != null ? + (ProgressBar) getView().findViewById(R.id.progress_loading) : null); + if (progress != null) { + progress.setVisibility(View.VISIBLE); + } + + RestRequest.Listener restListener = new RestRequest.Listener() { + @Override + public void onResponse(JSONObject jsonObject) { + if (isAdded()) { + if (progress != null) { + progress.setVisibility(View.GONE); + } + Comment comment = Comment.fromJSON(jsonObject); + if (comment != null) { + // save comment to local db if localBlogId is valid + if (localBlogId > 0) { + CommentTable.addComment(localBlogId, comment); + } + // now, at long last, show the comment + setComment(localBlogId, comment); + } + } + } + }; + RestRequest.ErrorListener restErrListener = new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + AppLog.e(T.COMMENTS, VolleyUtils.errStringFromVolleyError(volleyError), volleyError); + if (isAdded()) { + if (progress != null) { + progress.setVisibility(View.GONE); + } + ToastUtils.showToast(getActivity(), R.string.reader_toast_err_get_comment, ToastUtils.Duration.LONG); + } + } + }; + + final String path = String.format("/sites/%s/comments/%s", remoteBlogId, commentId); + WordPress.getRestClientUtils().get(path, restListener, restErrListener); + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDialogs.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDialogs.java new file mode 100644 index 000000000..b2ec720ed --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDialogs.java @@ -0,0 +1,52 @@ +package org.wordpress.android.ui.comments; + +import android.app.Activity; +import android.app.Dialog; +import android.app.ProgressDialog; + +import org.wordpress.android.R; + +/** + * Dialogs related to comment moderation displayed from CommentsActivity and NotificationsActivity + */ +class CommentDialogs { + public static final int ID_COMMENT_DLG_APPROVING = 100; + public static final int ID_COMMENT_DLG_DISAPPROVING = 101; + public static final int ID_COMMENT_DLG_SPAMMING = 102; + public static final int ID_COMMENT_DLG_TRASHING = 103; + public static final int ID_COMMENT_DLG_DELETING = 104; + + private CommentDialogs() { + throw new AssertionError(); + } + + public static Dialog createCommentDialog(Activity activity, int dialogId) { + final int resId; + switch (dialogId) { + case ID_COMMENT_DLG_APPROVING : + resId = R.string.dlg_approving_comments; + break; + case ID_COMMENT_DLG_DISAPPROVING: + resId = R.string.dlg_unapproving_comments; + break; + case ID_COMMENT_DLG_TRASHING: + resId = R.string.dlg_trashing_comments; + break; + case ID_COMMENT_DLG_SPAMMING: + resId = R.string.dlg_spamming_comments; + break; + case ID_COMMENT_DLG_DELETING: + resId = R.string.dlg_deleting_comments; + break; + default : + return null; + } + + ProgressDialog dialog = new ProgressDialog(activity); + dialog.setMessage(activity.getString(resId)); + dialog.setIndeterminate(true); + dialog.setCancelable(false); + + return dialog; + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentEvents.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentEvents.java new file mode 100644 index 000000000..88a82fd7f --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentEvents.java @@ -0,0 +1,56 @@ +package org.wordpress.android.ui.comments; + +import org.wordpress.android.models.CommentList; +import org.wordpress.android.models.CommentStatus; + +class CommentEvents { + + public static class CommentsBatchModerationFinishedEvent { + private final CommentList mComments; + private final boolean mIsDeleted; + + public CommentsBatchModerationFinishedEvent(CommentList comments, boolean isDeleted) { + mComments = comments; + mIsDeleted = isDeleted; + } + + public CommentList getComments() { + return mComments; + } + + public boolean isDeleted() { + return mIsDeleted; + } + + } + + public static class CommentModerationFinishedEvent { + private final boolean mIsSuccess; + private final boolean mIsCommentsRefreshRequired; + private final long mCommentId; + private final CommentStatus mNewStatus; + + public CommentModerationFinishedEvent(boolean isSuccess, boolean isCommentsRefreshRequired, long commentId, CommentStatus newStatus) { + mIsSuccess = isSuccess; + mIsCommentsRefreshRequired = isCommentsRefreshRequired; + mCommentId = commentId; + mNewStatus = newStatus; + } + + public boolean isSuccess() { + return mIsSuccess; + } + + public boolean isCommentsRefreshRequired() { + return mIsCommentsRefreshRequired; + } + + public long getCommentId() { + return mCommentId; + } + + public CommentStatus getNewStatus() { + return mNewStatus; + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentUtils.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentUtils.java new file mode 100644 index 000000000..d21d5231f --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentUtils.java @@ -0,0 +1,107 @@ +package org.wordpress.android.ui.comments; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.drawable.Drawable; +import android.support.v4.content.ContextCompat; +import android.text.Layout; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.style.LeadingMarginSpan; +import android.text.util.Linkify; +import android.widget.TextView; + +import org.wordpress.android.R; +import org.wordpress.android.WordPress; +import org.wordpress.android.util.EmoticonsUtils; +import org.wordpress.android.util.HtmlUtils; +import org.wordpress.android.util.helpers.WPImageGetter; + +public class CommentUtils { + /* + * displays comment text as html, including retrieving images + */ + public static void displayHtmlComment(TextView textView, String content, int maxImageSize) { + if (textView == null) { + return; + } + + if (content == null) { + textView.setText(null); + return; + } + + // skip performance hit of html conversion if content doesn't contain html + if (!content.contains("<") && !content.contains("&")) { + content = content.trim(); + textView.setText(content); + // make sure unnamed links are clickable + if (content.contains("://")) { + Linkify.addLinks(textView, Linkify.WEB_URLS); + } + return; + } + + // convert emoticons first (otherwise they'll be downloaded) + content = EmoticonsUtils.replaceEmoticonsWithEmoji(content); + + // now convert to HTML with an image getter that enforces a max image size + final Spanned html; + if (maxImageSize > 0 && content.contains("<img")) { + Drawable loading = ContextCompat.getDrawable(textView.getContext(), + R.drawable.legacy_dashicon_format_image_big_grey); + Drawable failed = ContextCompat.getDrawable(textView.getContext(), + R.drawable.noticon_warning_big_grey); + html = HtmlUtils.fromHtml(content, new WPImageGetter(textView, maxImageSize, WordPress.imageLoader, loading, + failed)); + } else { + html = HtmlUtils.fromHtml(content); + } + + // remove extra \n\n added by Html.convert() + int start = 0; + int end = html.length(); + while (start < end && Character.isWhitespace(html.charAt(start))) { + start++; + } + while (end > start && Character.isWhitespace(html.charAt(end - 1))) { + end--; + } + + textView.setText(html.subSequence(start, end)); + } + + // Assumes all lines after first line will not be indented + public static void indentTextViewFirstLine(TextView textView, int textOffsetX) { + if (textView == null || textOffsetX < 0) return; + + SpannableString text = new SpannableString(textView.getText()); + text.setSpan(new TextWrappingLeadingMarginSpan(textOffsetX), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + textView.setText(text); + } + + private static class TextWrappingLeadingMarginSpan implements LeadingMarginSpan.LeadingMarginSpan2 { + private final int margin; + private final int lines; + + public TextWrappingLeadingMarginSpan(int margin) { + this.margin = margin; + this.lines = 1; + } + + @Override + public int getLeadingMargin(boolean first) { + return first ? margin : 0; + } + + @Override + public void drawLeadingMargin(Canvas c, Paint p, int x, int dir, int top, int baseline, int bottom, CharSequence text, int start, int end, boolean first, Layout layout) { + + } + + @Override + public int getLeadingMarginLineCount() { + return lines; + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentsActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentsActivity.java new file mode 100644 index 000000000..e82645fe6 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentsActivity.java @@ -0,0 +1,314 @@ +package org.wordpress.android.ui.comments; + +import android.app.Dialog; +import android.app.Fragment; +import android.app.FragmentManager; +import android.app.FragmentTransaction; +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.design.widget.Snackbar; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.Toolbar; +import android.view.MenuItem; +import android.view.View; + +import org.wordpress.android.R; +import org.wordpress.android.WordPress; +import org.wordpress.android.models.Comment; +import org.wordpress.android.models.CommentList; +import org.wordpress.android.models.CommentStatus; +import org.wordpress.android.models.Note; +import org.wordpress.android.ui.ActivityId; +import org.wordpress.android.ui.ActivityLauncher; +import org.wordpress.android.ui.comments.CommentsListFragment.OnCommentSelectedListener; +import org.wordpress.android.ui.notifications.NotificationFragment; +import org.wordpress.android.ui.prefs.AppPrefs; +import org.wordpress.android.ui.reader.ReaderPostDetailFragment; +import org.wordpress.android.util.AppLog; + +import de.greenrobot.event.EventBus; + +public class CommentsActivity extends AppCompatActivity + implements OnCommentSelectedListener, + NotificationFragment.OnPostClickListener, + CommentActions.OnCommentActionListener, + CommentActions.OnCommentChangeListener { + private static final String KEY_SELECTED_COMMENT_ID = "selected_comment_id"; + static final String KEY_AUTO_REFRESHED = "has_auto_refreshed"; + static final String KEY_EMPTY_VIEW_MESSAGE = "empty_view_message"; + private static final String SAVED_COMMENTS_STATUS_TYPE = "saved_comments_status_type"; + private long mSelectedCommentId; + private final CommentList mTrashedComments = new CommentList(); + + private CommentStatus mCurrentCommentStatusType = CommentStatus.UNKNOWN; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.comment_activity); + + Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setElevation(0); + actionBar.setTitle(R.string.comments); + actionBar.setDisplayShowTitleEnabled(true); + actionBar.setDisplayHomeAsUpEnabled(true); + } + + if (getIntent() != null && getIntent().hasExtra(SAVED_COMMENTS_STATUS_TYPE)) { + mCurrentCommentStatusType = (CommentStatus) getIntent().getSerializableExtra(SAVED_COMMENTS_STATUS_TYPE); + } else { + // Read the value from app preferences here. Default to 0 - All + mCurrentCommentStatusType = AppPrefs.getCommentsStatusFilter(); + } + + if (savedInstanceState == null) { + CommentsListFragment commentsListFragment = new CommentsListFragment(); + // initialize comment status filter first time + commentsListFragment.setCommentStatusFilter(mCurrentCommentStatusType); + getFragmentManager().beginTransaction() + .add(R.id.layout_fragment_container, commentsListFragment, getString(R.string + .fragment_tag_comment_list)) + .commitAllowingStateLoss(); + } else { + getIntent().putExtra(KEY_AUTO_REFRESHED, savedInstanceState.getBoolean(KEY_AUTO_REFRESHED)); + getIntent().putExtra(KEY_EMPTY_VIEW_MESSAGE, savedInstanceState.getString(KEY_EMPTY_VIEW_MESSAGE)); + + mSelectedCommentId = savedInstanceState.getLong(KEY_SELECTED_COMMENT_ID); + } + + } + + @Override + public void onResume() { + super.onResume(); + ActivityId.trackLastActivity(ActivityId.COMMENTS); + } + + @Override + public void onBackPressed() { + if (getFragmentManager().getBackStackEntryCount() > 0) { + getFragmentManager().popBackStack(); + } else { + super.onBackPressed(); + } + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + AppLog.d(AppLog.T.COMMENTS, "comment activity new intent"); + } + + + private CommentDetailFragment getDetailFragment() { + Fragment fragment = getFragmentManager().findFragmentByTag(getString( + R.string.fragment_tag_comment_detail)); + if (fragment == null) { + return null; + } + return (CommentDetailFragment) fragment; + } + + private boolean hasDetailFragment() { + return (getDetailFragment() != null); + } + + private CommentsListFragment getListFragment() { + Fragment fragment = getFragmentManager().findFragmentByTag(getString( + R.string.fragment_tag_comment_list)); + if (fragment == null) { + return null; + } + return (CommentsListFragment) fragment; + } + + private boolean hasListFragment() { + return (getListFragment() != null); + } + + private void showReaderFragment(long remoteBlogId, long postId) { + FragmentManager fm = getFragmentManager(); + fm.executePendingTransactions(); + + Fragment fragment = ReaderPostDetailFragment.newInstance(remoteBlogId, postId); + FragmentTransaction ft = fm.beginTransaction(); + String tagForFragment = getString(R.string.fragment_tag_reader_post_detail); + ft.add(R.id.layout_fragment_container, fragment, tagForFragment) + .addToBackStack(tagForFragment) + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE); + if (hasDetailFragment()) + ft.hide(getDetailFragment()); + ft.commit(); + } + + /* + * called from comment list when user taps a comment + */ + @Override + public void onCommentSelected(long commentId) { + mSelectedCommentId = commentId; + FragmentManager fm = getFragmentManager(); + if (fm == null) return; + + fm.executePendingTransactions(); + CommentsListFragment listFragment = getListFragment(); + + FragmentTransaction ft = fm.beginTransaction(); + String tagForFragment = getString(R.string.fragment_tag_comment_detail); + CommentDetailFragment detailFragment = CommentDetailFragment.newInstance(WordPress.getCurrentLocalTableBlogId(), + commentId); + ft.add(R.id.layout_fragment_container, detailFragment, tagForFragment).addToBackStack(tagForFragment) + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE); + if (listFragment != null) { + ft.hide(listFragment); + } + ft.commitAllowingStateLoss(); + } + + /* + * called from comment detail when user taps a link to a post - show the post in a + * reader detail fragment + */ + @Override + public void onPostClicked(Note note, int remoteBlogId, int postId) { + showReaderFragment(remoteBlogId, postId); + } + + /* + * reload the comment list from existing data + */ + private void reloadCommentList() { + CommentsListFragment listFragment = getListFragment(); + if (listFragment != null) + listFragment.loadComments(); + } + + /* + * tell the comment list to get recent comments from server + */ + private void updateCommentList() { + CommentsListFragment listFragment = getListFragment(); + if (listFragment != null) { + //listFragment.setRefreshing(true); + listFragment.setCommentStatusFilter(mCurrentCommentStatusType); + listFragment.updateComments(false); + } + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + // https://code.google.com/p/android/issues/detail?id=19917 + if (outState.isEmpty()) { + outState.putBoolean("bug_19917_fix", true); + } + + // retain the id of the highlighted and selected comments + if (mSelectedCommentId != 0 && hasDetailFragment()) { + outState.putLong(KEY_SELECTED_COMMENT_ID, mSelectedCommentId); + } + + if (hasListFragment()) { + outState.putBoolean(KEY_AUTO_REFRESHED, getListFragment().mHasAutoRefreshedComments); + outState.putString(KEY_EMPTY_VIEW_MESSAGE, getListFragment().getEmptyViewMessage()); + } + super.onSaveInstanceState(outState); + } + + @Override + protected Dialog onCreateDialog(int id) { + Dialog dialog = CommentDialogs.createCommentDialog(this, id); + if (dialog != null) + return dialog; + return super.onCreateDialog(id); + } + + @Override + public void onModerateComment(final int accountId, final Comment comment, final CommentStatus newStatus) { + FragmentManager fm = getFragmentManager(); + if (fm.getBackStackEntryCount() > 0) { + fm.popBackStack(); + } + + if (newStatus == CommentStatus.APPROVED || newStatus == CommentStatus.UNAPPROVED) { + getListFragment().setCommentIsModerating(comment.commentID, true); + getListFragment().updateEmptyView(); + CommentActions.moderateComment(accountId, comment, newStatus, + new CommentActions.CommentActionListener() { + @Override + public void onActionResult(CommentActionResult result) { + EventBus.getDefault().post(new CommentEvents.CommentModerationFinishedEvent + (result.isSuccess(), true, comment.commentID, newStatus)); + } + }); + } else if (newStatus == CommentStatus.SPAM || newStatus == CommentStatus.TRASH || newStatus == CommentStatus.DELETE) { + mTrashedComments.add(comment); + getListFragment().removeComment(comment); + getListFragment().setCommentIsModerating(comment.commentID, true); + getListFragment().updateEmptyView(); + + String message = (newStatus == CommentStatus.TRASH ? getString(R.string.comment_trashed) : newStatus == CommentStatus.SPAM ? getString(R.string.comment_spammed) : getString(R.string.comment_deleted_permanently)); + View.OnClickListener undoListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + mTrashedComments.remove(comment); + getListFragment().setCommentIsModerating(comment.commentID, false); + getListFragment().loadComments(); + } + }; + + Snackbar snackbar = Snackbar.make(getListFragment().getView(), message, Snackbar.LENGTH_LONG) + .setAction(R.string.undo, undoListener); + + // do the actual moderation once the undo bar has been hidden + snackbar.setCallback(new Snackbar.Callback() { + @Override + public void onDismissed(Snackbar snackbar, int event) { + super.onDismissed(snackbar, event); + + // comment will no longer exist in moderating list if action was undone + if (!mTrashedComments.contains(comment)) { + return; + } + mTrashedComments.remove(comment); + CommentActions.moderateComment(accountId, comment, newStatus, new CommentActions.CommentActionListener() { + @Override + public void onActionResult(CommentActionResult result) { + EventBus.getDefault().post(new CommentEvents.CommentModerationFinishedEvent + (result.isSuccess(), true, comment.commentID, newStatus)); + } + }); + } + }); + + snackbar.show(); + } + } + + @Override + public void onCommentChanged(CommentActions.ChangeType changeType) { + switch (changeType) { + case EDITED: + reloadCommentList(); + break; + case REPLIED: + updateCommentList(); + break; + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } + return super.onOptionsItemSelected(item); + } + +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentsListFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentsListFragment.java new file mode 100644 index 000000000..1b61519cc --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentsListFragment.java @@ -0,0 +1,795 @@ +package org.wordpress.android.ui.comments; + +import android.app.AlertDialog; +import android.app.Fragment; +import android.content.DialogInterface; +import android.graphics.drawable.Drawable; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.v4.content.ContextCompat; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.view.ActionMode; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; + +import org.wordpress.android.R; +import org.wordpress.android.WordPress; +import org.wordpress.android.datasets.CommentTable; +import org.wordpress.android.models.Blog; +import org.wordpress.android.models.Comment; +import org.wordpress.android.models.CommentList; +import org.wordpress.android.models.CommentStatus; +import org.wordpress.android.models.FilterCriteria; +import org.wordpress.android.ui.EmptyViewMessageType; +import org.wordpress.android.ui.FilteredRecyclerView; +import org.wordpress.android.ui.prefs.AppPrefs; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.NetworkUtils; +import org.wordpress.android.util.ToastUtils; +import org.xmlrpc.android.ApiHelper; +import org.xmlrpc.android.ApiHelper.ErrorType; +import org.xmlrpc.android.XMLRPCFault; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import de.greenrobot.event.EventBus; + +public class CommentsListFragment extends Fragment implements CommentAdapter.OnDataLoadedListener, + CommentAdapter.OnLoadMoreListener, CommentAdapter.OnSelectedItemsChangeListener, CommentAdapter.OnCommentPressedListener { + + interface OnCommentSelectedListener { + void onCommentSelected(long commentId); + } + + private boolean mIsUpdatingComments = false; + private boolean mCanLoadMoreComments = true; + boolean mHasAutoRefreshedComments = false; + + private final CommentStatus[] commentStatuses = {CommentStatus.UNKNOWN, CommentStatus.UNAPPROVED, + CommentStatus.APPROVED, CommentStatus.TRASH, CommentStatus.SPAM}; + + private EmptyViewMessageType mEmptyViewMessageType = EmptyViewMessageType.NO_CONTENT; + private FilteredRecyclerView mFilteredCommentsView; + private CommentAdapter mAdapter; + private ActionMode mActionMode; + private CommentStatus mCommentStatusFilter; + + private UpdateCommentsTask mUpdateCommentsTask; + + public static final int COMMENTS_PER_PAGE = 30; + + private CommentAdapterState mCommentAdapterState; + + + private boolean hasAdapter() { + return (mAdapter != null); + } + + private int getSelectedCommentCount() { + return getAdapter().getSelectedCommentCount(); + } + + public void removeComment(Comment comment) { + if (hasAdapter() && comment != null) { + getAdapter().removeComment(comment); + } + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + Bundle extras = getActivity().getIntent().getExtras(); + if (extras != null) { + mHasAutoRefreshedComments = extras.getBoolean(CommentsActivity.KEY_AUTO_REFRESHED); + mEmptyViewMessageType = EmptyViewMessageType.getEnumFromString(extras.getString( + CommentsActivity.KEY_EMPTY_VIEW_MESSAGE)); + } else { + mHasAutoRefreshedComments = false; + mEmptyViewMessageType = EmptyViewMessageType.NO_CONTENT; + } + + if (savedInstanceState != null) { + mCommentAdapterState = savedInstanceState.getParcelable(CommentAdapterState.KEY); + } + + if (!NetworkUtils.isNetworkAvailable(getActivity())) { + mFilteredCommentsView.updateEmptyView(EmptyViewMessageType.NETWORK_ERROR); + return; + } + + // Restore the empty view's message + mFilteredCommentsView.updateEmptyView(mEmptyViewMessageType); + + if (!mHasAutoRefreshedComments) { + updateComments(false); + mFilteredCommentsView.setRefreshing(true); + mHasAutoRefreshedComments = true; + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.comment_list_fragment, container, false); + + mFilteredCommentsView = (FilteredRecyclerView) view.findViewById(R.id.filtered_recycler_view); + mFilteredCommentsView.setLogT(AppLog.T.COMMENTS); + mFilteredCommentsView.setFilterListener(new FilteredRecyclerView.FilterListener() { + @Override + public List<FilterCriteria> onLoadFilterCriteriaOptions(boolean refresh) { + @SuppressWarnings("unchecked") + ArrayList<FilterCriteria> criteria = new ArrayList(); + Collections.addAll(criteria, commentStatuses); + return criteria; + } + + @Override + public void onLoadFilterCriteriaOptionsAsync(FilteredRecyclerView.FilterCriteriaAsyncLoaderListener listener, boolean refresh) { + } + + @Override + public void onLoadData() { + updateComments(false); + } + + @Override + public void onFilterSelected(int position, FilterCriteria criteria) { + //trackCommentsAnalytics(); + AppPrefs.setCommentsStatusFilter((CommentStatus) criteria); + mCommentStatusFilter = (CommentStatus) criteria; + finishActionMode(); + } + + @Override + public FilterCriteria onRecallSelection() { + mCommentStatusFilter = AppPrefs.getCommentsStatusFilter(); + return mCommentStatusFilter; + } + + @Override + public String onShowEmptyViewMessage(EmptyViewMessageType emptyViewMsgType) { + + if (emptyViewMsgType == EmptyViewMessageType.NO_CONTENT) { + FilterCriteria filter = mFilteredCommentsView.getCurrentFilter(); + if (filter == null || CommentStatus.UNKNOWN.equals(filter)) { + return getString(R.string.comments_empty_list); + } else { + switch (mCommentStatusFilter) { + case APPROVED: + return getString(R.string.comments_empty_list_filtered_approved); + case UNAPPROVED: + return getString(R.string.comments_empty_list_filtered_pending); + case SPAM: + return getString(R.string.comments_empty_list_filtered_spam); + case TRASH: + return getString(R.string.comments_empty_list_filtered_trashed); + default: + return getString(R.string.comments_empty_list); + } + } + + } else { + int stringId = 0; + switch (emptyViewMsgType) { + case LOADING: + stringId = R.string.comments_fetching; + break; + case NETWORK_ERROR: + stringId = R.string.no_network_message; + break; + case PERMISSION_ERROR: + stringId = R.string.error_refresh_unauthorized_comments; + break; + case GENERIC_ERROR: + stringId = R.string.error_refresh_comments; + break; + } + return getString(stringId); + } + + } + + @Override + public void onShowCustomEmptyView(EmptyViewMessageType emptyViewMsgType) { + } + }); + + // the following will change the look and feel of the toolbar to match the current design + mFilteredCommentsView.setToolbarBackgroundColor(ContextCompat.getColor(getActivity(), R.color.blue_medium)); + mFilteredCommentsView.setToolbarSpinnerTextColor(ContextCompat.getColor(getActivity(), R.color.white)); + mFilteredCommentsView.setToolbarSpinnerDrawable(R.drawable.arrow); + mFilteredCommentsView.setToolbarLeftAndRightPadding( + getResources().getDimensionPixelSize(R.dimen.margin_filter_spinner), + getResources().getDimensionPixelSize(R.dimen.margin_none)); + + return view; + } + + @Override + public void onResume() { + super.onResume(); + if (mFilteredCommentsView.getAdapter() == null) { + mFilteredCommentsView.setAdapter(getAdapter()); + if (!NetworkUtils.isNetworkAvailable(getActivity())) { + ToastUtils.showToast(getActivity(), getString(R.string.error_refresh_comments_showing_older)); + } + getAdapter().loadComments(mCommentStatusFilter); + } + } + + public void setCommentStatusFilter(CommentStatus statusFilter) { + mCommentStatusFilter = statusFilter; + } + + private void moderateSelectedComments(final CommentStatus newStatus) { + if (!NetworkUtils.checkConnection(getActivity())) return; + + final CommentList selectedComments = getAdapter().getSelectedComments(); + final CommentList updateComments = new CommentList(); + + // build list of comments whose status is different than passed + for (Comment comment : selectedComments) { + if (comment.getStatusEnum() != newStatus) { + setCommentIsModerating(comment.commentID, true); + updateComments.add(comment); + } + + } + if (updateComments.size() == 0) return; + + CommentActions.OnCommentsModeratedListener listener = new CommentActions.OnCommentsModeratedListener() { + @Override + public void onCommentsModerated(final CommentList moderatedComments) { + EventBus.getDefault().post( + new CommentEvents.CommentsBatchModerationFinishedEvent(moderatedComments, false)); + } + }; + + getAdapter().clearSelectedComments(); + finishActionMode(); + + CommentActions.moderateComments( + WordPress.getCurrentLocalTableBlogId(), + updateComments, + newStatus, + listener); + } + + + private void confirmDeleteComments() { + if (CommentStatus.TRASH.equals(mCommentStatusFilter)) { + AlertDialog.Builder dialogBuilder = new AlertDialog.Builder( + getActivity()); + dialogBuilder.setTitle(getResources().getText(R.string.delete)); + int resId = getAdapter().getSelectedCommentCount() > 1 ? R.string.dlg_sure_to_delete_comments : R.string.dlg_sure_to_delete_comment; + dialogBuilder.setMessage(getResources().getText(resId)); + dialogBuilder.setPositiveButton(getResources().getText(R.string.yes), + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int whichButton) { + deleteSelectedComments(true); + } + }); + dialogBuilder.setNegativeButton( + getResources().getText(R.string.no), + null); + dialogBuilder.setCancelable(true); + dialogBuilder.create().show(); + + } else { + + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setMessage(R.string.dlg_confirm_trash_comments); + builder.setTitle(R.string.trash); + builder.setCancelable(true); + builder.setPositiveButton(R.string.trash_yes, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + deleteSelectedComments(false); + } + }); + builder.setNegativeButton(R.string.trash_no, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + dialog.cancel(); + } + }); + AlertDialog alert = builder.create(); + alert.show(); + } + } + + private void deleteSelectedComments(boolean deletePermanently) { + if (!NetworkUtils.checkConnection(getActivity())) return; + + + final CommentList selectedComments = getAdapter().getSelectedComments(); + + for (Comment comment : selectedComments) { + setCommentIsModerating(comment.commentID, true); + } + + final CommentStatus newStatus = deletePermanently ? CommentStatus.DELETE : CommentStatus.TRASH; + + + CommentActions.OnCommentsModeratedListener listener = new CommentActions.OnCommentsModeratedListener() { + @Override + public void onCommentsModerated(final CommentList deletedComments) { + EventBus.getDefault().post( + new CommentEvents.CommentsBatchModerationFinishedEvent(deletedComments, true)); + } + }; + + getAdapter().clearSelectedComments(); + CommentActions.moderateComments( + WordPress.getCurrentLocalTableBlogId(), selectedComments, newStatus, listener); + } + + void loadComments() { + // this is called from CommentsActivity when a comment was changed in the detail view, + // and the change will already be in SQLite so simply reload the comment adapter + // to show the change + getAdapter().loadComments(mCommentStatusFilter); + } + + void updateEmptyView() { + //this is called from CommentsActivity in the case the last moment for a given type has been changed from that + //status, leaving the list empty, so we need to update the empty view. The method inside FilteredRecyclerView + //does the handling itself, so we only check for null here. + if (mFilteredCommentsView != null) { + mFilteredCommentsView.updateEmptyView(EmptyViewMessageType.NO_CONTENT); + } + } + + /* + * get latest comments from server, or pass loadMore=true to get comments beyond the + * existing ones + */ + void updateComments(boolean loadMore) { + if (mIsUpdatingComments) { + AppLog.w(AppLog.T.COMMENTS, "update comments task already running"); + return; + } else if (!NetworkUtils.isNetworkAvailable(getActivity())) { + mFilteredCommentsView.updateEmptyView(EmptyViewMessageType.NETWORK_ERROR); + mFilteredCommentsView.setRefreshing(false); + ToastUtils.showToast(getActivity(), getString(R.string.error_refresh_comments_showing_older)); + //we're offline, load/refresh whatever we have in our local db + getAdapter().loadComments(mCommentStatusFilter); + return; + } + + //immediately load/refresh whatever we have in our local db as we wait for the API call to get latest results + if (!loadMore) { + getAdapter().loadComments(mCommentStatusFilter); + } + + mFilteredCommentsView.updateEmptyView(EmptyViewMessageType.LOADING); + + mUpdateCommentsTask = new UpdateCommentsTask(loadMore, mCommentStatusFilter); + mUpdateCommentsTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + public void setCommentIsModerating(long commentId, boolean isModerating) { + if (!hasAdapter()) return; + + if (isModerating) { + getAdapter().addModeratingCommentId(commentId); + } else { + getAdapter().removeModeratingCommentId(commentId); + } + } + + public String getEmptyViewMessage() { + return mEmptyViewMessageType.name(); + } + + /* + * task to retrieve latest comments from server + */ + private class UpdateCommentsTask extends AsyncTask<Void, Void, CommentList> { + ErrorType mErrorType = ErrorType.NO_ERROR; + final boolean mIsLoadingMore; + final CommentStatus mStatusFilter; + + private UpdateCommentsTask(boolean loadMore, CommentStatus statusFilter) { + mIsLoadingMore = loadMore; + mStatusFilter = statusFilter; + } + + @Override + protected void onPreExecute() { + super.onPreExecute(); + mIsUpdatingComments = true; + if (mIsLoadingMore) { + mFilteredCommentsView.showLoadingProgress(); + } + } + + @Override + protected void onCancelled() { + super.onCancelled(); + mIsUpdatingComments = false; + mUpdateCommentsTask = null; + mFilteredCommentsView.setRefreshing(false); + } + + @Override + protected CommentList doInBackground(Void... args) { + if (!isAdded()) { + return null; + } + + final Blog blog = WordPress.getCurrentBlog(); + if (blog == null) { + mErrorType = ErrorType.INVALID_CURRENT_BLOG; + return null; + } + + Map<String, Object> hPost = new HashMap<>(); + if (mIsLoadingMore) { + int numExisting = getAdapter().getItemCount(); + hPost.put("offset", numExisting); + hPost.put("number", COMMENTS_PER_PAGE); + } else { + hPost.put("number", COMMENTS_PER_PAGE); + } + + if (mStatusFilter != null) { + //if this is UNKNOWN that means show ALL, i.e., do not apply filter + if (!mStatusFilter.equals(CommentStatus.UNKNOWN)) { + hPost.put("status", CommentStatus.toString(mStatusFilter)); + } + } + + Object[] params = {blog.getRemoteBlogId(), + blog.getUsername(), + blog.getPassword(), + hPost}; + try { + return ApiHelper.refreshComments(blog, params, new ApiHelper.DatabasePersistCallback() { + @Override + public void onDataReadyToSave(List list) { + int localBlogId = blog.getLocalTableBlogId(); + + if (!mIsLoadingMore) { //existing comments should be deleted only if we are not "loading more" + CommentTable.deleteCommentsForBlogWithFilter(localBlogId, mStatusFilter); + } + CommentTable.saveComments(localBlogId, (CommentList) list); + } + }); + } catch (XMLRPCFault xmlrpcFault) { + mErrorType = ErrorType.UNKNOWN_ERROR; + if (xmlrpcFault.getFaultCode() == 401) { + mErrorType = ErrorType.UNAUTHORIZED; + } + } catch (Exception e) { + mErrorType = ErrorType.UNKNOWN_ERROR; + } + return null; + } + + protected void onPostExecute(CommentList comments) { + + boolean isRefreshing = mFilteredCommentsView.isRefreshing(); + mIsUpdatingComments = false; + mUpdateCommentsTask = null; + + if (!isAdded()) return; + + if (mIsLoadingMore) { + mFilteredCommentsView.hideLoadingProgress(); + } + mFilteredCommentsView.setRefreshing(false); + + if (isCancelled()) return; + + mCanLoadMoreComments = (comments != null && comments.size() > 0); + + // result will be null on error OR if no more comments exists + if (comments == null && !getActivity().isFinishing() && mErrorType != ErrorType.NO_ERROR) { + switch (mErrorType) { + case UNAUTHORIZED: + if (!mFilteredCommentsView.emptyViewIsVisible()) { + ToastUtils.showToast(getActivity(), getString(R.string.error_refresh_unauthorized_comments)); + } + mFilteredCommentsView.updateEmptyView(EmptyViewMessageType.PERMISSION_ERROR); + return; + default: + ToastUtils.showToast(getActivity(), getString(R.string.error_refresh_comments)); + mFilteredCommentsView.updateEmptyView(EmptyViewMessageType.GENERIC_ERROR); + return; + } + } + + if (!getActivity().isFinishing()) { + if (comments != null && comments.size() > 0) { + getAdapter().loadComments(mStatusFilter); + } else { + if (isRefreshing) { + //if refreshing and no errors, we only want freshest stuff, so clear old data + getAdapter().clearComments(); + } + mFilteredCommentsView.updateEmptyView(EmptyViewMessageType.NO_CONTENT); + } + } + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + if (outState.isEmpty()) { + outState.putBoolean("bug_19917_fix", true); + } + + if (hasAdapter()) { + outState.putParcelable(CommentAdapterState.KEY, getAdapter().getAdapterState()); + } + + super.onSaveInstanceState(outState); + } + + /**** + * Contextual ActionBar (CAB) routines + ***/ + private void updateActionModeTitle() { + if (mActionMode == null) + return; + int numSelected = getSelectedCommentCount(); + if (numSelected > 0) { + mActionMode.setTitle(Integer.toString(numSelected)); + } else { + mActionMode.setTitle(""); + } + } + + private void finishActionMode() { + if (mActionMode != null) { + mActionMode.finish(); + } + } + + private final class ActionModeCallback implements ActionMode.Callback { + @Override + public boolean onCreateActionMode(ActionMode actionMode, Menu menu) { + mActionMode = actionMode; + MenuInflater inflater = actionMode.getMenuInflater(); + inflater.inflate(R.menu.menu_comments_cab, menu); + mFilteredCommentsView.setSwipeToRefreshEnabled(false); + return true; + } + + private void setItemEnabled(Menu menu, int menuId, boolean isEnabled) { + final MenuItem item = menu.findItem(menuId); + if (item == null || item.isEnabled() == isEnabled) + return; + item.setEnabled(isEnabled); + if (item.getIcon() != null) { + // must mutate the drawable to avoid affecting other instances of it + Drawable icon = item.getIcon().mutate(); + icon.setAlpha(isEnabled ? 255 : 128); + item.setIcon(icon); + } + } + + @Override + public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) { + final CommentList selectedComments = getAdapter().getSelectedComments(); + + boolean hasSelection = (selectedComments.size() > 0); + boolean hasApproved = hasSelection && selectedComments.hasAnyWithStatus(CommentStatus.APPROVED); + boolean hasUnapproved = hasSelection && selectedComments.hasAnyWithStatus(CommentStatus.UNAPPROVED); + boolean hasSpam = hasSelection && selectedComments.hasAnyWithStatus(CommentStatus.SPAM); + boolean hasAnyNonSpam = hasSelection && selectedComments.hasAnyWithoutStatus(CommentStatus.SPAM); + boolean hasTrash = hasSelection && selectedComments.hasAnyWithStatus(CommentStatus.TRASH); + + setItemEnabled(menu, R.id.menu_approve, hasUnapproved || hasSpam || hasTrash); + setItemEnabled(menu, R.id.menu_unapprove, hasApproved); + setItemEnabled(menu, R.id.menu_spam, hasAnyNonSpam); + setItemEnabled(menu, R.id.menu_trash, hasSelection); + + final MenuItem trashItem = menu.findItem(R.id.menu_trash); + if (trashItem != null) { + if (CommentStatus.TRASH.equals(mCommentStatusFilter)) { + trashItem.setTitle(R.string.mnu_comment_delete_permanently); + } + } + + return true; + } + + @Override + public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) { + int numSelected = getSelectedCommentCount(); + if (numSelected == 0) + return false; + + int i = menuItem.getItemId(); + if (i == R.id.menu_approve) { + moderateSelectedComments(CommentStatus.APPROVED); + return true; + } else if (i == R.id.menu_unapprove) { + moderateSelectedComments(CommentStatus.UNAPPROVED); + return true; + } else if (i == R.id.menu_spam) { + moderateSelectedComments(CommentStatus.SPAM); + return true; + } else if (i == R.id.menu_trash) {// unlike the other status changes, we ask the user to confirm trashing + confirmDeleteComments(); + return true; + } else { + return false; + } + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + getAdapter().setEnableSelection(false); + mFilteredCommentsView.setSwipeToRefreshEnabled(true); + mActionMode = null; + } + } + + private CommentAdapter getAdapter() { + if (mAdapter == null) { + mAdapter = new CommentAdapter(getActivity(), WordPress.getCurrentLocalTableBlogId()); + mAdapter.setInitialState(mCommentAdapterState); + mAdapter.setOnCommentPressedListener(this); + mAdapter.setOnDataLoadedListener(this); + mAdapter.setOnLoadMoreListener(this); + mAdapter.setOnSelectedItemsChangeListener(this); + } + + return mAdapter; + } + + + // adapter calls this when selected comments have changed (CAB) + @Override + public void onSelectedItemsChanged() { + if (mActionMode != null) { + if (getSelectedCommentCount() == 0) { + mActionMode.finish(); + } else { + updateActionModeTitle(); + // must invalidate to ensure onPrepareActionMode is called + mActionMode.invalidate(); + } + } + } + + @Override + public void onCommentPressed(int position, View view) { + // if the comment is being moderated ignore the press + Comment comment = getAdapter().getItem(position); + if (!isCommentSelectable(comment)) { + return; + } + + if (mActionMode == null) { + mFilteredCommentsView.invalidate(); + if (getActivity() instanceof OnCommentSelectedListener) { + ((OnCommentSelectedListener) getActivity()).onCommentSelected(comment.commentID); + } + } else { + getAdapter().toggleItemSelected(position, view); + } + } + + @Override + public void onCommentLongPressed(int position, View view) { + // if the comment is being moderated ignore the press + Comment comment = getAdapter().getItem(position); + if (!isCommentSelectable(comment)) { + return; + } + + // enable CAB if it's not already enabled + if (mActionMode == null) { + if (getActivity() instanceof AppCompatActivity) { + ((AppCompatActivity) getActivity()).startSupportActionMode(new ActionModeCallback()); + getAdapter().setEnableSelection(true); + getAdapter().setItemSelected(position, true, view); + } + } else { + getAdapter().toggleItemSelected(position, view); + } + } + + private boolean isCommentSelectable(Comment comment){ + return comment != null && !getAdapter().isModeratingCommentId(comment.commentID); + } + + private boolean shouldRestoreCab() { + return hasAdapter() && !getAdapter().getSelectedCommentsId().isEmpty() && mActionMode == null; + } + + private void restoreCab() { + if (getActivity() instanceof AppCompatActivity) { + ((AppCompatActivity) getActivity()).startSupportActionMode(new ActionModeCallback()); + updateActionModeTitle(); + } + } + + @Override + public void onStart() { + super.onStart(); + EventBus.getDefault().register(this); + } + + @Override + public void onStop() { + EventBus.getDefault().unregister(this); + super.onStop(); + } + + @SuppressWarnings("unused") + public void onEventMainThread(CommentEvents.CommentModerationFinishedEvent event) { + if (!isAdded()) return; + + setCommentIsModerating(event.getCommentId(), false); + + if (!event.isSuccess()) { + ToastUtils.showToast(getActivity(), R.string.error_moderate_comment, ToastUtils.Duration.LONG); + } + + if (event.isCommentsRefreshRequired() || event.getNewStatus() != mCommentStatusFilter) { + loadComments(); + } + } + + @SuppressWarnings("unused") + public void onEventMainThread(CommentEvents.CommentsBatchModerationFinishedEvent moderatedComments) { + if (!isAdded()) return; + + if (moderatedComments.getComments().size() > 0) { + for (Comment comment : moderatedComments.getComments()) { + setCommentIsModerating(comment.commentID, false); + } + + if (moderatedComments.isDeleted()) { + getAdapter().deleteComments(moderatedComments.getComments()); + } else { + getAdapter().replaceComments(moderatedComments.getComments()); + } + + loadComments(); + } else { + ToastUtils.showToast(getActivity(), R.string.error_moderate_comment); + } + } + + + // called after comments have been loaded + @Override + public void onDataLoaded(boolean isEmpty) { + if (!isAdded()) return; + + if (!isEmpty) { + // After comments are loaded, we should check if some of them are selected and show CAB if necessary + if (shouldRestoreCab()) { + restoreCab(); + } + + // Hide the empty view if there are already some displayed comments + mFilteredCommentsView.hideEmptyView(); + } else if (!mIsUpdatingComments) { + // Change LOADING to NO_CONTENT message + mFilteredCommentsView.updateEmptyView(EmptyViewMessageType.NO_CONTENT); + } + } + + @Override + public void onLoadMore() { + if (mCanLoadMoreComments && !mIsUpdatingComments) { + updateComments(true); + } + } + +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/EditCommentActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/EditCommentActivity.java new file mode 100644 index 000000000..fc784483c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/EditCommentActivity.java @@ -0,0 +1,440 @@ +package org.wordpress.android.ui.comments; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.ProgressDialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.EditText; +import android.widget.ProgressBar; + +import com.android.volley.VolleyError; +import com.simperium.client.BucketObjectMissingException; +import com.wordpress.rest.RestRequest; + +import org.json.JSONObject; +import org.wordpress.android.R; +import org.wordpress.android.WordPress; +import org.wordpress.android.datasets.CommentTable; +import org.wordpress.android.models.Blog; +import org.wordpress.android.models.Comment; +import org.wordpress.android.models.CommentStatus; +import org.wordpress.android.models.Note; +import org.wordpress.android.ui.ActivityId; +import org.wordpress.android.ui.notifications.utils.SimperiumUtils; +import org.wordpress.android.util.NetworkUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.EditTextUtils; +import org.wordpress.android.util.ToastUtils; +import org.wordpress.android.util.VolleyUtils; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlrpc.android.ApiHelper.Method; +import org.xmlrpc.android.XMLRPCClientInterface; +import org.xmlrpc.android.XMLRPCException; +import org.xmlrpc.android.XMLRPCFactory; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +public class EditCommentActivity extends AppCompatActivity { + static final String ARG_LOCAL_BLOG_ID = "blog_id"; + static final String ARG_COMMENT_ID = "comment_id"; + static final String ARG_NOTE_ID = "note_id"; + + private static final int ID_DIALOG_SAVING = 0; + + private int mLocalBlogId; + private long mCommentId; + private Comment mComment; + private Note mNote; + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + + setContentView(R.layout.comment_edit_activity); + setTitle(getString(R.string.edit_comment)); + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayShowTitleEnabled(true); + actionBar.setDisplayHomeAsUpEnabled(true); + } + + loadComment(getIntent()); + + ActivityId.trackLastActivity(ActivityId.COMMENT_EDITOR); + } + + private void loadComment(Intent intent) { + if (intent == null) { + showErrorAndFinish(); + return; + } + + mLocalBlogId = intent.getIntExtra(ARG_LOCAL_BLOG_ID, 0); + mCommentId = intent.getLongExtra(ARG_COMMENT_ID, 0); + final String noteId = intent.getStringExtra(ARG_NOTE_ID); + if (noteId == null) { + mComment = CommentTable.getComment(mLocalBlogId, mCommentId); + if (mComment == null) { + showErrorAndFinish(); + return; + } + + configureViews(); + } else { + if (SimperiumUtils.getNotesBucket() != null) { + try { + mNote = SimperiumUtils.getNotesBucket().get(noteId); + requestFullCommentForNote(mNote); + } catch (BucketObjectMissingException e) { + showErrorAndFinish(); + } + } + } + } + + private void showErrorAndFinish() { + ToastUtils.showToast(this, R.string.error_load_comment); + finish(); + } + + private void configureViews() { + final EditText editAuthorName = (EditText) this.findViewById(R.id.author_name); + editAuthorName.setText(mComment.getAuthorName()); + + final EditText editAuthorEmail = (EditText) this.findViewById(R.id.author_email); + editAuthorEmail.setText(mComment.getAuthorEmail()); + + final EditText editAuthorUrl = (EditText) this.findViewById(R.id.author_url); + editAuthorUrl.setText(mComment.getAuthorUrl()); + + // REST API can currently only edit comment content + if (mNote != null) { + editAuthorName.setVisibility(View.GONE); + editAuthorEmail.setVisibility(View.GONE); + editAuthorUrl.setVisibility(View.GONE); + } + + final EditText editContent = (EditText) this.findViewById(R.id.edit_comment_content); + editContent.setText(mComment.getCommentText()); + + // show error when comment content is empty + editContent.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + @Override + public void afterTextChanged(Editable s) { + boolean hasError = (editContent.getError() != null); + boolean hasText = (s != null && s.length() > 0); + if (!hasText && !hasError) { + editContent.setError(getString(R.string.content_required)); + } else if (hasText && hasError) { + editContent.setError(null); + } + } + }); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.edit_comment, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + int i = item.getItemId(); + if (i == android.R.id.home) { + onBackPressed(); + return true; + } else if (i == R.id.menu_save_comment) { + saveComment(); + return true; + } else { + return super.onOptionsItemSelected(item); + } + } + + private String getEditTextStr(int resId) { + final EditText edit = (EditText) findViewById(resId); + return EditTextUtils.getText(edit); + } + + private void saveComment() { + // make sure comment content was entered + final EditText editContent = (EditText) findViewById(R.id.edit_comment_content); + if (EditTextUtils.isEmpty(editContent)) { + editContent.setError(getString(R.string.content_required)); + return; + } + + // return immediately if comment hasn't changed + if (!isCommentEdited()) { + ToastUtils.showToast(this, R.string.toast_comment_unedited); + return; + } + + // make sure we have an active connection + if (!NetworkUtils.checkConnection(this)) + return; + + if (mNote != null) { + // Edit comment via REST API :) + showSaveDialog(); + WordPress.getRestClientUtils().editCommentContent(mNote.getSiteId(), + mNote.getCommentId(), + EditTextUtils.getText(editContent), + new RestRequest.Listener() { + @Override + public void onResponse(JSONObject response) { + if (isFinishing()) return; + + dismissSaveDialog(); + setResult(RESULT_OK); + finish(); + } + }, new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError error) { + if (isFinishing()) return; + + dismissSaveDialog(); + showEditErrorAlert(); + } + }); + } else { + // Edit comment via XML-RPC :( + if (mIsUpdateTaskRunning) + AppLog.w(AppLog.T.COMMENTS, "update task already running"); + new UpdateCommentTask().execute(); + } + } + + /* + * returns true if user made any changes to the comment + */ + private boolean isCommentEdited() { + if (mComment == null) + return false; + + final String authorName = getEditTextStr(R.id.author_name); + final String authorEmail = getEditTextStr(R.id.author_email); + final String authorUrl = getEditTextStr(R.id.author_url); + final String content = getEditTextStr(R.id.edit_comment_content); + + return !(authorName.equals(mComment.getAuthorName()) + && authorEmail.equals(mComment.getAuthorEmail()) + && authorUrl.equals(mComment.getAuthorUrl()) + && content.equals(mComment.getCommentText())); + } + + @Override + protected Dialog onCreateDialog(int id) { + if (id == ID_DIALOG_SAVING) { + ProgressDialog savingDialog = new ProgressDialog(this); + savingDialog.setMessage(getResources().getText(R.string.saving_changes)); + savingDialog.setIndeterminate(true); + savingDialog.setCancelable(true); + return savingDialog; + } + return super.onCreateDialog(id); + } + + private void showSaveDialog() { + showDialog(ID_DIALOG_SAVING); + } + + private void dismissSaveDialog() { + try { + dismissDialog(ID_DIALOG_SAVING); + } catch (IllegalArgumentException e) { + // dialog doesn't exist + } + } + + /* + * AsyncTask to save comment to server + */ + private boolean mIsUpdateTaskRunning = false; + private class UpdateCommentTask extends AsyncTask<Void, Void, Boolean> { + @Override + protected void onPreExecute() { + mIsUpdateTaskRunning = true; + showSaveDialog(); + } + @Override + protected void onCancelled() { + mIsUpdateTaskRunning = false; + dismissSaveDialog(); + } + @Override + protected Boolean doInBackground(Void... params) { + final Blog blog; + blog = WordPress.wpDB.instantiateBlogByLocalId(mLocalBlogId); + if (blog == null) { + AppLog.e(AppLog.T.COMMENTS, "Invalid local blog id:" + mLocalBlogId); + return false; + } + final String authorName = getEditTextStr(R.id.author_name); + final String authorEmail = getEditTextStr(R.id.author_email); + final String authorUrl = getEditTextStr(R.id.author_url); + final String content = getEditTextStr(R.id.edit_comment_content); + + final Map<String, String> postHash = new HashMap<>(); + + // using CommentStatus.toString() rather than getStatus() ensures that the XML-RPC + // status value is used - important since comment may have been loaded via the + // REST API, which uses different status values + postHash.put("status", CommentStatus.toString(mComment.getStatusEnum())); + postHash.put("content", content); + postHash.put("author", authorName); + postHash.put("author_url", authorUrl); + postHash.put("author_email", authorEmail); + + XMLRPCClientInterface client = XMLRPCFactory.instantiate(blog.getUri(), blog.getHttpuser(), + blog.getHttppassword()); + Object[] xmlParams = {blog.getRemoteBlogId(), blog.getUsername(), blog.getPassword(), Long.toString( + mCommentId), postHash}; + + try { + Object result = client.call(Method.EDIT_COMMENT, xmlParams); + boolean isSaved = (result != null && Boolean.parseBoolean(result.toString())); + if (isSaved) { + mComment.setAuthorEmail(authorEmail); + mComment.setAuthorUrl(authorUrl); + mComment.setAuthorName(authorName); + mComment.setCommentText(content); + CommentTable.updateComment(mLocalBlogId, mComment); + } + return isSaved; + } catch (XMLRPCException e) { + AppLog.e(AppLog.T.COMMENTS, e); + return false; + } catch (IOException e) { + AppLog.e(AppLog.T.COMMENTS, e); + return false; + } catch (XmlPullParserException e) { + AppLog.e(AppLog.T.COMMENTS, e); + return false; + } + } + @Override + protected void onPostExecute(Boolean result) { + if (isFinishing()) return; + + mIsUpdateTaskRunning = false; + dismissSaveDialog(); + + if (result) { + setResult(RESULT_OK); + finish(); + } else { + // alert user to error + showEditErrorAlert(); + } + } + } + + private void showEditErrorAlert() { + AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(EditCommentActivity.this); + dialogBuilder.setTitle(getResources().getText(R.string.error)); + dialogBuilder.setMessage(R.string.error_edit_comment); + dialogBuilder.setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int whichButton) { + // just close the dialog + } + }); + dialogBuilder.setCancelable(true); + dialogBuilder.create().show(); + } + + // Request a comment via the REST API for a note + private void requestFullCommentForNote(Note note) { + if (isFinishing()) return; + final ProgressBar progress = (ProgressBar)findViewById(R.id.edit_comment_progress); + final View editContainer = findViewById(R.id.edit_comment_container); + + if (progress == null || editContainer == null) { + return; + } + + editContainer.setVisibility(View.GONE); + progress.setVisibility(View.VISIBLE); + + RestRequest.Listener restListener = new RestRequest.Listener() { + @Override + public void onResponse(JSONObject jsonObject) { + if (!isFinishing()) { + progress.setVisibility(View.GONE); + editContainer.setVisibility(View.VISIBLE); + Comment comment = Comment.fromJSON(jsonObject); + if (comment != null) { + mComment = comment; + configureViews(); + } else { + showErrorAndFinish(); + } + } + } + }; + RestRequest.ErrorListener restErrListener = new RestRequest.ErrorListener() { + @Override + public void onErrorResponse(VolleyError volleyError) { + AppLog.e(AppLog.T.COMMENTS, VolleyUtils.errStringFromVolleyError(volleyError), volleyError); + if (!isFinishing()) { + progress.setVisibility(View.GONE); + showErrorAndFinish(); + } + } + }; + + final String path = String.format("/sites/%s/comments/%s", note.getSiteId(), note.getCommentId()); + WordPress.getRestClientUtils().get(path, restListener, restErrListener); + } + + @Override + public void onBackPressed() { + if (isCommentEdited()) { + AlertDialog.Builder dialogBuilder = new AlertDialog.Builder( + EditCommentActivity.this); + dialogBuilder.setTitle(getResources().getText(R.string.cancel_edit)); + dialogBuilder.setMessage(getResources().getText(R.string.sure_to_cancel_edit_comment)); + dialogBuilder.setPositiveButton(getResources().getText(R.string.yes), + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int whichButton) { + finish(); + } + }); + dialogBuilder.setNegativeButton( + getResources().getText(R.string.no), + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int whichButton) { + // just close the dialog + } + }); + dialogBuilder.setCancelable(true); + dialogBuilder.create().show(); + } else { + super.onBackPressed(); + } + } +} |