diff --git a/WordPress/src/androidTest/java/org/wordpress/android/ui/notifications/NotificationsUtilsTest.kt b/WordPress/src/androidTest/java/org/wordpress/android/ui/notifications/NotificationsUtilsTest.kt index 5dcc36f90040..136d704382e9 100644 --- a/WordPress/src/androidTest/java/org/wordpress/android/ui/notifications/NotificationsUtilsTest.kt +++ b/WordPress/src/androidTest/java/org/wordpress/android/ui/notifications/NotificationsUtilsTest.kt @@ -51,7 +51,7 @@ class NotificationsUtilsTest { // Check if the link is correctly set val spans = result.getSpans(10, 14, ClickableSpan::class.java) assertTrue(spans.size == 1) - assertEquals("https://example.com", (spans[0] as NoteBlockClickableSpan).formattableRange.url) + assertEquals("https://example.com", (spans[0] as NoteBlockClickableSpan).formattableRange?.url) } @Test diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml index dfb16f0c711f..774e8b9cfb2d 100644 --- a/WordPress/src/main/AndroidManifest.xml +++ b/WordPress/src/main/AndroidManifest.xml @@ -234,7 +234,7 @@ android:name=".ui.comments.CommentsDetailActivity" android:theme="@style/WordPress.NoActionBar" android:windowSoftInputMode="adjustResize" - android:label="@string/comments"/> + android:label=""/> mEnabledActions = EnumSet.allOf(EnabledActions.class); + private CommentDetailViewModel mViewModel; - @Nullable private CommentDetailFragmentBinding mBinding = null; - @Nullable private ReaderIncludeCommentBoxBinding mReplyBinding = null; - @Nullable private CommentActionFooterBinding mActionBinding = null; + private final OnActionClickListener mOnActionClickListener = new OnActionClickListener() { + @Override public void onEditCommentClicked() { + editComment(); + } - /* - * used when called from comment list - */ - @SuppressWarnings("deprecation") - static CommentDetailFragment newInstance( - @NonNull SiteModel site, - CommentModel commentModel - ) { - CommentDetailFragment fragment = new CommentDetailFragment(); - Bundle args = new Bundle(); - args.putSerializable(KEY_MODE, CommentSource.SITE_COMMENTS); - args.putInt(KEY_SITE_LOCAL_ID, site.getId()); - args.putLong(KEY_COMMENT_ID, commentModel.getRemoteCommentId()); - fragment.setArguments(args); - return fragment; - } + @Override public void onUserInfoClicked() { + UserProfileBottomSheetFragment.newInstance(getUserProfileUiState()) + .show(getChildFragmentManager(), UserProfileBottomSheetFragment.TAG); + } - /* - * used when called from notification list for a comment notification - */ - @SuppressWarnings("deprecation") - public static CommentDetailFragment newInstance(final String noteId, final String replyText) { - CommentDetailFragment fragment = new CommentDetailFragment(); - Bundle args = new Bundle(); - args.putSerializable(KEY_MODE, CommentSource.NOTIFICATION); - args.putString(KEY_NOTE_ID, noteId); - args.putString(KEY_REPLY_TEXT, replyText); - fragment.setArguments(args); - return fragment; - } + @Override public void onShareClicked() { + if (getContext() != null) { + ActivityLauncher.openShareIntent(getContext(), mComment.getUrl(), null); + } + } + + @Override public void onChangeStatusClicked() { + showModerationBottomSheet(); + } + }; + + abstract void showModerationBottomSheet(); + + abstract UserProfileUiState getUserProfileUiState(); @Override @SuppressWarnings("deprecation") public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); ((WordPress) requireActivity().getApplication()).component().inject(this); - + mViewModel = new ViewModelProvider(this).get(CommentDetailViewModel.class); mCommentSource = (CommentSource) requireArguments().getSerializable(KEY_MODE); - - switch (mCommentSource) { - case SITE_COMMENTS: - setComment(requireArguments().getLong(KEY_COMMENT_ID), requireArguments().getInt(KEY_SITE_LOCAL_ID)); - break; - case NOTIFICATION: - setNote(requireArguments().getString(KEY_NOTE_ID)); - setReplyText(requireArguments().getString(KEY_REPLY_TEXT)); - break; - } - - if (savedInstanceState != null) { - if (savedInstanceState.getString(KEY_NOTE_ID) != null) { - // The note will be set in onResume() - // See WordPress.deferredInit() - mRestoredNoteId = savedInstanceState.getString(KEY_NOTE_ID); - } else { - int siteId = savedInstanceState.getInt(KEY_SITE_LOCAL_ID); - long commentId = savedInstanceState.getLong(KEY_COMMENT_ID); - setComment(commentId, siteId); - } - } - setHasOptionsMenu(true); } @@ -272,142 +199,18 @@ public View onCreateView( @Nullable Bundle savedInstanceState ) { mBinding = CommentDetailFragmentBinding.inflate(inflater, container, false); - mReplyBinding = mBinding.layoutCommentBox; - mActionBinding = CommentActionFooterBinding.inflate(inflater, null, false); - mMediumOpacity = ResourcesCompat.getFloat( getResources(), com.google.android.material.R.dimen.material_emphasis_medium ); - ElevationOverlayProvider elevationOverlayProvider = new ElevationOverlayProvider( - mBinding.getRoot().getContext() - ); - float appbarElevation = getResources().getDimension(R.dimen.appbar_elevation); - int elevatedColor = elevationOverlayProvider.compositeOverlayWithThemeSurfaceColorIfNeeded(appbarElevation); - - mReplyBinding.layoutContainer.setBackgroundColor(elevatedColor); - - mReplyBinding.btnSubmitReply.setEnabled(false); - mReplyBinding.btnSubmitReply.setOnLongClickListener(view1 -> { - if (view1.isHapticFeedbackEnabled()) { - view1.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); - } - - Toast.makeText(view1.getContext(), R.string.send, Toast.LENGTH_SHORT).show(); - return true; - }); - ViewExtensionsKt.redirectContextClickToLongPressListener(mReplyBinding.btnSubmitReply); - - mReplyBinding.editComment.initializeWithPrefix('@'); - mReplyBinding.editComment.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(@NonNull CharSequence s, int start, int count, int after) { - } - - @Override - public void onTextChanged(@NonNull CharSequence s, int start, int before, int count) { - } - - @Override - public void afterTextChanged(@NonNull Editable s) { - mReplyBinding.btnSubmitReply.setEnabled(!TextUtils.isEmpty(s.toString().trim())); - } - }); - - mReplyBinding.buttonExpand.setOnClickListener( - v -> { - if (mSite != null && mComment != null) { - Bundle bundle = CommentFullScreenDialogFragment.Companion.newBundle( - mReplyBinding.editComment.getText().toString(), - mReplyBinding.editComment.getSelectionStart(), - mReplyBinding.editComment.getSelectionEnd(), - mSite.getSiteId() - ); - - new Builder(requireContext()) - .setTitle(R.string.comment) - .setOnCollapseListener(this) - .setOnConfirmListener(this) - .setContent(CommentFullScreenDialogFragment.class, bundle) - .setAction(R.string.send) - .setHideActivityBar(true) - .build() - .show(requireActivity().getSupportFragmentManager(), - getCommentSpecificFragmentTagSuffix(mComment)); - } - } - ); - mReplyBinding.buttonExpand.setOnLongClickListener(v -> { - if (v.isHapticFeedbackEnabled()) { - v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); - } - - Toast.makeText(v.getContext(), R.string.description_expand, Toast.LENGTH_SHORT).show(); - return true; - }); - ViewExtensionsKt.redirectContextClickToLongPressListener(mReplyBinding.buttonExpand); - setReplyUniqueId(mReplyBinding, mSite, mComment, mNote); - - // hide comment like button until we know it can be enabled in showCommentAsNotification() - mActionBinding.btnLike.setVisibility(View.GONE); - - // hide moderation buttons until updateModerationButtons() is called - mActionBinding.layoutButtons.setVisibility(View.GONE); - // this is necessary in order for anchor tags in the comment text to be clickable mBinding.textContent.setLinksClickable(true); mBinding.textContent.setMovementMethod(WPLinkMovementMethod.getInstance()); - mReplyBinding.editComment.setHint(R.string.reader_hint_comment_on_comment); - mReplyBinding.editComment.setOnEditorActionListener((v, actionId, event) -> { - if (mSite != null && mComment != null - && (actionId == EditorInfo.IME_ACTION_DONE || actionId == EditorInfo.IME_ACTION_SEND)) { - submitReply(mReplyBinding, mSite, mComment); - } - return false; - }); - - if (!TextUtils.isEmpty(mRestoredReplyText)) { - mReplyBinding.editComment.setText(mRestoredReplyText); - mRestoredReplyText = null; - } - - mReplyBinding.btnSubmitReply.setOnClickListener(v -> { - if (mSite != null && mComment != null) { - submitReply(mReplyBinding, mSite, mComment); - } - }); - - mActionBinding.btnSpam.setOnClickListener(v -> { - if (mSite != null && mComment != null) { - if (CommentStatus.fromString(mComment.getStatus()) == CommentStatus.SPAM) { - moderateComment(mBinding, mActionBinding, mSite, mComment, mNote, CommentStatus.APPROVED); - announceCommentStatusChangeForAccessibility(CommentStatus.UNSPAM); - } else { - moderateComment(mBinding, mActionBinding, mSite, mComment, mNote, CommentStatus.SPAM); - announceCommentStatusChangeForAccessibility(CommentStatus.SPAM); - } - } - }); - - mActionBinding.btnLike.setOnClickListener(v -> { - if (mSite != null && mComment != null) { - likeComment(mActionBinding, mSite, mComment, false); - } - }); - - mActionBinding.btnMore.setOnClickListener(v -> { - if (mSite != null && mComment != null) { - showMoreMenu(mBinding, mActionBinding, mSite, mComment, mNote, v); - } - }); - // hide more button until we know it can be enabled - mActionBinding.btnMore.setVisibility(View.GONE); - - if (mSite != null) { - setupSuggestionServiceAndAdapter(mReplyBinding, mSite); - } + mBinding.imageMore.setOnClickListener(v -> + CommentActionPopupHandler.show(mBinding.imageMore, mOnActionClickListener) + ); return mBinding.getRoot(); } @@ -419,94 +222,13 @@ private String getCommentSpecificFragmentTagSuffix(@NonNull CommentModel comment + comment.getRemoteCommentId(); } - @Override - public void onConfirm(@Nullable Bundle result) { - if (mReplyBinding != null && result != null && mSite != null && mComment != null) { - mReplyBinding.editComment.setText(result.getString(CommentFullScreenDialogFragment.RESULT_REPLY)); - submitReply(mReplyBinding, mSite, mComment); - } - } - - @Override - public void onCollapse(@Nullable Bundle result) { - if (mReplyBinding != null && result != null) { - mReplyBinding.editComment.setText(result.getString(CommentFullScreenDialogFragment.RESULT_REPLY)); - mReplyBinding.editComment.setSelection(result.getInt( - CommentFullScreenDialogFragment.RESULT_SELECTION_START), - result.getInt(CommentFullScreenDialogFragment.RESULT_SELECTION_END)); - mReplyBinding.editComment.requestFocus(); - } - } - @Override public void onResume() { super.onResume(); ActivityId.trackLastActivity(ActivityId.COMMENT_DETAIL); - - // Set the note if we retrieved the noteId from savedInstanceState - if (!TextUtils.isEmpty(mRestoredNoteId)) { - setNote(mRestoredNoteId); - mRestoredNoteId = null; - } - - CollapseFullScreenDialogFragment fragment = null; - if (mComment != null) { - // reattach listeners to collapsible reply dialog - // we need to to it in onResume to make sure mComment is already initialized - fragment = (CollapseFullScreenDialogFragment) requireActivity() - .getSupportFragmentManager().findFragmentByTag(getCommentSpecificFragmentTagSuffix(mComment)); - } - - if (fragment != null && fragment.isAdded()) { - fragment.setOnCollapseListener(this); - fragment.setOnConfirmListener(this); - } } - private void setupSuggestionServiceAndAdapter( - @NonNull ReaderIncludeCommentBoxBinding replyBinding, - @NonNull SiteModel site - ) { - if (!isAdded() || !SiteUtils.isAccessedViaWPComRest(site)) { - return; - } - mSuggestionServiceConnectionManager = new SuggestionServiceConnectionManager(getActivity(), site.getSiteId()); - mSuggestionAdapter = SuggestionUtils.setupUserSuggestions( - site, - requireActivity(), - mSuggestionServiceConnectionManager - ); - replyBinding.editComment.setAdapter(mSuggestionAdapter); - } - - private void setReplyUniqueId( - @NonNull ReaderIncludeCommentBoxBinding replyBinding, - @Nullable SiteModel site, - @Nullable CommentModel comment, - @Nullable Note note - ) { - if (isAdded()) { - String sId = null; - if (site != null && comment != null) { - sId = String.format(Locale.US, "%d-%d", site.getSiteId(), comment.getRemoteCommentId()); - } else if (note != null) { - sId = String.format(Locale.US, "%d-%d", note.getSiteId(), note.getCommentId()); - } - if (sId != null) { - replyBinding.editComment.getAutoSaveTextHelper().setUniqueId(sId); - replyBinding.editComment.getAutoSaveTextHelper().loadString(replyBinding.editComment); - } - } - } - - private void setComment(final long commentRemoteId, final int siteLocalId) { - final SiteModel site = mSiteStore.getSiteByLocalId(siteLocalId); - if (site != null) { - setComment(site, mCommentsStoreAdapter.getCommentBySiteAndRemoteId(site, commentRemoteId)); - } - } - - private void setComment( + protected void setComment( @NonNull final SiteModel site, @Nullable final CommentModel comment ) { @@ -517,29 +239,25 @@ private void setComment( // notification about a reply to a comment this user posted on someone else's blog mIsUsersBlog = (comment != null); - if (mBinding != null && mReplyBinding != null && mActionBinding != null) { - showComment(mBinding, mReplyBinding, mActionBinding, mSite, mComment, mNote); + if (mBinding != null) { + showComment(mBinding, mSite, mComment, mNote); } - // Reset the reply unique id since mComment just changed. - if (mReplyBinding != null) setReplyUniqueId(mReplyBinding, mSite, mComment, mNote); + updateModerationStatus(); } - private void disableShouldFocusReplyField() { - mShouldFocusReplyField = false; - } + abstract void updateModerationStatus(); public void enableShouldFocusReplyField() { mShouldFocusReplyField = true; } @Nullable - @Override - public Note getNote() { + private Note getNote() { return mNote; } - private SiteModel createDummyWordPressComSite(long siteId) { + protected SiteModel createDummyWordPressComSite(long siteId) { SiteModel site = new SiteModel(); site.setIsWPCom(true); site.setOrigin(SiteModel.ORIGIN_WPCOM_REST); @@ -547,48 +265,6 @@ private SiteModel createDummyWordPressComSite(long siteId) { return site; } - public void setNote(@NonNull Note note) { - mNote = note; - mSite = mSiteStore.getSiteBySiteId(note.getSiteId()); - if (mSite == null) { - // This should not exist, we should clean that screen so a note without a site/comment can be displayed - mSite = createDummyWordPressComSite(mNote.getSiteId()); - } - if (mBinding != null && mReplyBinding != null && mActionBinding != null) { - showComment(mBinding, mReplyBinding, mActionBinding, mSite, mComment, mNote); - } - } - - @Override - public void setNote(String noteId) { - if (noteId == null) { - showErrorToastAndFinish(); - return; - } - - Note note = NotificationsTable.getNoteById(noteId); - if (note == null) { - showErrorToastAndFinish(); - } else { - setNote(note); - } - } - - private void setReplyText(String replyText) { - if (replyText == null) { - return; - } - mRestoredReplyText = replyText; - } - - private void showErrorToastAndFinish() { - AppLog.e(AppLog.T.NOTIFS, "Note could not be found."); - if (getActivity() != null) { - ToastUtils.showToast(getActivity(), R.string.error_notification_open); - getActivity().finish(); - } - } - @SuppressWarnings("deprecation") // TODO: Remove when minSdkVersion >= 23 public void onAttach(@NonNull Activity activity) { super.onAttach(activity); @@ -608,8 +284,8 @@ public void onStart() { super.onStart(); EventBus.getDefault().register(this); mCommentsStoreAdapter.register(this); - if (mBinding != null && mReplyBinding != null && mActionBinding != null && mSite != null) { - showComment(mBinding, mReplyBinding, mActionBinding, mSite, mComment, mNote); + if (mBinding != null && mSite != null) { + showComment(mBinding, mSite, mComment, mNote); } } @@ -671,7 +347,7 @@ private void reloadComment( * open the comment for editing */ @SuppressWarnings("deprecation") - private void editComment(@NonNull SiteModel site) { + private void editComment() { if (!isAdded()) { return; } @@ -679,19 +355,19 @@ private void editComment(@NonNull SiteModel site) { AnalyticsUtils.trackCommentActionWithSiteDetails( Stat.COMMENT_EDITOR_OPENED, mCommentSource.toAnalyticsCommentActionSource(), - site + mSite ); } // 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 - final CommentIdentifier commentIdentifier = mapCommentIdentifier(); + final CommentIdentifier commentIdentifier = getCommentIdentifier(); if (commentIdentifier != null) { final Intent intent = UnifiedCommentsEditActivity.createIntent( requireActivity(), commentIdentifier, - site + mSite ); startActivityForResult(intent, INTENT_COMMENT_EDITOR); } else { @@ -699,34 +375,13 @@ private void editComment(@NonNull SiteModel site) { } } - @Nullable - private CommentIdentifier mapCommentIdentifier() { - if (mCommentSource == null) return null; - switch (mCommentSource) { - case SITE_COMMENTS: - if (mComment != null) { - return new SiteCommentIdentifier(mComment.getId(), mComment.getRemoteCommentId()); - } else { - return null; - } - case NOTIFICATION: - if (mNote != null) { - return new NotificationCommentIdentifier(mNote.getId(), mNote.getCommentId()); - } else { - return null; - } - default: - return null; - } - } + abstract CommentIdentifier getCommentIdentifier(); /* * display the current comment */ - private void showComment( + protected void showComment( @NonNull CommentDetailFragmentBinding binding, - @NonNull ReaderIncludeCommentBoxBinding replyBinding, - @NonNull CommentActionFooterBinding actionBinding, @NonNull SiteModel site, @Nullable CommentModel comment, @Nullable Note note @@ -737,16 +392,14 @@ private void showComment( if (comment == null) { // Hide container views when comment is null (will happen when opened from a notification). - showCommentWhenNull(binding, replyBinding, actionBinding, note); + showCommentWhenNull(binding, note); } else { - showCommentWhenNonNull(binding, replyBinding, actionBinding, site, comment, note); + showCommentWhenNonNull(binding, site, comment); } } private void showCommentWhenNull( @NonNull CommentDetailFragmentBinding binding, - @NonNull ReaderIncludeCommentBoxBinding replyBinding, - @NonNull CommentActionFooterBinding actionBinding, @Nullable Note note ) { // These two views contain all the other views except the progress bar. @@ -765,7 +418,7 @@ private void showCommentWhenNull( CommentModel comment = mCommentsStoreAdapter.getCommentBySiteAndRemoteId(site, note.getCommentId()); if (comment != null) { // It exists, then show it as a "Notification" - showCommentAsNotification(binding, replyBinding, actionBinding, site, comment, note); + showCommentAsNotification(binding, site, comment, note); } else { // It's not in our store yet, request it. RemoteCommentPayload payload = new RemoteCommentPayload(site, note.getCommentId()); @@ -774,36 +427,23 @@ private void showCommentWhenNull( // Show a "temporary" comment built from the note data, the view will be refreshed once the // comment has been fetched. - showCommentAsNotification(binding, replyBinding, actionBinding, site, null, note); + showCommentAsNotification(binding, site, null, note); } } } private void showCommentWhenNonNull( @NonNull CommentDetailFragmentBinding binding, - @NonNull ReaderIncludeCommentBoxBinding replyBinding, - @NonNull CommentActionFooterBinding actionBinding, @NonNull SiteModel site, - @NonNull CommentModel comment, - @Nullable Note note + @NonNull CommentModel comment ) { // These two views contain all the other views except the progress bar. binding.nestedScrollView.setVisibility(View.VISIBLE); binding.layoutBottom.setVisibility(View.VISIBLE); - // Add action buttons footer - if (note == null && actionBinding.layoutButtons.getParent() == null) { - binding.commentContentContainer.addView(actionBinding.layoutButtons); - } - binding.textName.setText( comment.getAuthorName() == null ? getString(R.string.anonymous) : comment.getAuthorName() ); - binding.textDate.setText( - DateTimeUtils.javaDateToTimeSpan( - DateTimeUtils.dateFromIso8601(comment.getDatePublished()), WordPress.getContext() - ) - ); String renderingError = getString(R.string.comment_unable_to_show_error); binding.textContent.post(() -> CommentUtils.displayHtmlComment( @@ -824,35 +464,17 @@ private void showCommentWhenNonNull( } mImageManager.loadIntoCircle(binding.imageAvatar, ImageType.AVATAR_WITH_BACKGROUND, avatarUrl); - updateStatusViews(binding, actionBinding, site, comment, note); - // navigate to author's blog when avatar or name clicked if (comment.getAuthorUrl() != null) { View.OnClickListener authorListener = v -> ReaderActivityLauncher.openUrl(getActivity(), comment.getAuthorUrl()); binding.imageAvatar.setOnClickListener(authorListener); binding.textName.setOnClickListener(authorListener); - binding.textName.setTextColor(ContextExtensionsKt.getColorFromAttribute( - binding.textName.getContext(), - com.google.android.material.R.attr.colorPrimary) - ); - } else { - binding.textName.setTextColor(ContextExtensionsKt.getColorFromAttribute( - binding.textName.getContext(), - com.google.android.material.R.attr.colorOnSurface) - ); } showPostTitle(binding, comment, site); - - // make sure reply box is showing - if (replyBinding.layoutContainer.getVisibility() != View.VISIBLE && canReply()) { - AniUtils.animateBottomBar(replyBinding.layoutContainer, true); - if (mShouldFocusReplyField) { - replyBinding.editComment.performClick(); - disableShouldFocusReplyField(); - } - } + binding.textSite.setText(DateTimeUtils.javaDateToTimeSpan( + DateTimeUtils.dateFromIso8601(comment.getDatePublished()), WordPress.getContext())); requireActivity().invalidateOptionsMenu(); } @@ -863,8 +485,7 @@ private void showCommentWhenNonNull( private void setPostTitle( @NonNull CommentDetailFragmentBinding binding, @NonNull CommentModel comment, - String postTitle, - boolean isHyperlink + String postTitle ) { if (!isAdded()) { return; @@ -880,22 +501,7 @@ private void setPostTitle( mCommentsStoreAdapter.dispatch(CommentActionBuilder.newUpdateCommentAction(comment)); } - // display "on [Post Title]..." - if (isHyperlink) { - String html = getString(R.string.on) - + " " - + postTitle.trim() - + ""; - binding.textPostTitle.setText(HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY)); - } else { - String text = getString(R.string.on) + " " + postTitle.trim(); - binding.textPostTitle.setText(text); - } + binding.textPostTitle.setText(postTitle.trim()); } /* @@ -932,7 +538,7 @@ private void showPostTitle( hasTitle = false; } if (hasTitle) { - setPostTitle(binding, comment, title, canRequestPost); + setPostTitle(binding, comment, title); } else if (canRequestPost) { binding.textPostTitle.setText(postExists ? R.string.untitled : R.string.loading); } @@ -962,7 +568,7 @@ public void onSuccess(String blogUrl) { comment.getRemotePostId() ); if (!TextUtils.isEmpty(postTitle)) { - setPostTitle(binding, comment, postTitle, true); + setPostTitle(binding, comment, postTitle); } else { binding.textPostTitle.setText(R.string.untitled); } @@ -975,7 +581,7 @@ public void onFailure(int statusCode) { }); } - binding.textPostTitle.setOnClickListener(v -> { + binding.headerView.setOnClickListener(v -> { if (mOnPostClickListener != null) { mOnPostClickListener.onPostClicked( getNote(), @@ -992,9 +598,13 @@ public void onFailure(int statusCode) { ); } }); + + handleHeaderVisibility(); } } + abstract void handleHeaderVisibility(); + // TODO klymyam remove legacy comment tracking after new comments are shipped and new funnels are made private void trackModerationEvent(final CommentStatus newStatus) { if (mCommentSource == null) return; @@ -1048,9 +658,7 @@ private void trackModerationEvent(final CommentStatus newStatus) { /* * approve, disapprove, spam, or trash the current comment */ - private void moderateComment( - @NonNull CommentDetailFragmentBinding binding, - @NonNull CommentActionFooterBinding actionBinding, + protected void moderateComment( @NonNull SiteModel site, @NonNull CommentModel comment, @Nullable Note note, @@ -1082,291 +690,12 @@ private void moderateComment( // Fire the appropriate listener if we have one if (note != null && mOnNoteCommentActionListener != null) { mOnNoteCommentActionListener.onModerateCommentForNote(note, newStatus); - dispatchModerationAction(site, comment, newStatus); + mViewModel.dispatchModerationAction(site, comment, newStatus); } else if (mOnCommentActionListener != null) { mOnCommentActionListener.onModerateComment(comment, newStatus); // Sad, but onModerateComment does the moderation itself (due to the undo bar), this should be refactored, // That's why we don't call dispatchModerationAction() here. } - - updateStatusViews(binding, actionBinding, site, comment, note); - } - - private void dispatchModerationAction( - @NonNull SiteModel site, - @NonNull CommentModel comment, - CommentStatus newStatus - ) { - if (newStatus == CommentStatus.DELETED) { - // For deletion, we need to dispatch a specific action. - mCommentsStoreAdapter.dispatch( - CommentActionBuilder.newDeleteCommentAction(new RemoteCommentPayload(site, comment)) - ); - } else { - // Actual moderation (push the modified comment). - comment.setStatus(newStatus.toString()); - mCommentsStoreAdapter.dispatch( - CommentActionBuilder.newPushCommentAction(new RemoteCommentPayload(site, comment)) - ); - } - } - - /* - * post comment box text as a reply to the current comment - */ - @SuppressWarnings("deprecation") - private void submitReply( - @NonNull ReaderIncludeCommentBoxBinding replyBinding, - @NonNull SiteModel site, - @NonNull CommentModel comment - ) { - if (!isAdded() || mIsSubmittingReply) { - return; - } - - if (!NetworkUtils.checkConnection(getActivity())) { - return; - } - - final String replyText = EditTextUtils.getText(replyBinding.editComment); - if (TextUtils.isEmpty(replyText)) { - return; - } - - // disable editor, hide soft keyboard, hide submit icon, and show progress spinner while submitting - replyBinding.editComment.setEnabled(false); - EditTextUtils.hideSoftInput(replyBinding.editComment); - replyBinding.btnSubmitReply.setVisibility(View.GONE); - replyBinding.progressSubmitComment.setVisibility(View.VISIBLE); - - mIsSubmittingReply = true; - - if (mCommentSource != null) { - AnalyticsUtils.trackCommentReplyWithDetails( - false, - site, - comment, - mCommentSource.toAnalyticsCommentActionSource() - ); - } - - // Pseudo comment reply - CommentModel reply = new CommentModel(); - reply.setContent(replyText); - - mCommentsStoreAdapter.dispatch( - CommentActionBuilder.newCreateNewCommentAction(new RemoteCreateCommentPayload(site, comment, reply)) - ); - } - - /* - * update the text, drawable & click listener for mBtnModerate based on - * the current status of the comment, show mBtnSpam if the comment isn't - * already marked as spam, and show the current status of the comment - */ - private void updateStatusViews( - @NonNull CommentDetailFragmentBinding binding, - @NonNull CommentActionFooterBinding actionBinding, - @NonNull SiteModel site, - @NonNull CommentModel comment, - @Nullable Note note - ) { - if (!isAdded()) { - return; - } - - final int statusTextResId; // string resource id for status text - final int statusColor; // color for status text - - CommentStatus commentStatus = CommentStatus.fromString(comment.getStatus()); - switch (commentStatus) { - case APPROVED: - statusTextResId = R.string.comment_status_approved; - statusColor = ContextExtensionsKt.getColorFromAttribute( - requireActivity(), - R.attr.wpColorWarningDark - ); - break; - case UNAPPROVED: - statusTextResId = R.string.comment_status_unapproved; - statusColor = ContextExtensionsKt.getColorFromAttribute( - requireActivity(), - R.attr.wpColorWarningDark - ); - break; - case SPAM: - statusTextResId = R.string.comment_status_spam; - statusColor = ContextExtensionsKt.getColorFromAttribute( - requireActivity(), - com.google.android.material.R.attr.colorError - ); - break; - case DELETED: - case ALL: - case UNREPLIED: - case UNSPAM: - case UNTRASH: - case TRASH: - default: - statusTextResId = R.string.comment_status_trash; - statusColor = ContextExtensionsKt.getColorFromAttribute( - requireActivity(), - com.google.android.material.R.attr.colorError - ); - break; - } - - if (canLike(site)) { - actionBinding.btnLike.setVisibility(View.VISIBLE); - toggleLikeButton(actionBinding, comment.getILike()); - } - - // comment status is only shown if this comment is from one of this user's blogs and the - // comment hasn't been CommentStatus.APPROVED - if (mIsUsersBlog && commentStatus != CommentStatus.APPROVED) { - binding.textStatus.setText(getString(statusTextResId).toUpperCase(Locale.getDefault())); - binding.textStatus.setTextColor(statusColor); - if (binding.textStatus.getVisibility() != View.VISIBLE) { - binding.textStatus.clearAnimation(); - AniUtils.fadeIn(binding.textStatus, AniUtils.Duration.LONG); - } - } else { - binding.textStatus.setVisibility(View.GONE); - } - - if (canModerate()) { - setModerateButtonForStatus(actionBinding, commentStatus); - actionBinding.btnModerate.setOnClickListener( - v -> performModerateAction(binding, actionBinding, site, comment, note) - ); - actionBinding.btnModerate.setVisibility(View.VISIBLE); - } else { - actionBinding.btnModerate.setVisibility(View.GONE); - } - - if (canMarkAsSpam()) { - actionBinding.btnSpam.setVisibility(View.VISIBLE); - if (commentStatus == CommentStatus.SPAM) { - actionBinding.btnSpamText.setText(R.string.mnu_comment_unspam); - } else { - actionBinding.btnSpamText.setText(R.string.mnu_comment_spam); - } - } else { - actionBinding.btnSpam.setVisibility(View.GONE); - } - - if (canTrash()) { - if (commentStatus == CommentStatus.TRASH) { - ColorUtils.setImageResourceWithTint( - actionBinding.btnModerateIcon, - R.drawable.ic_undo_white_24dp, - ContextExtensionsKt.getColorResIdFromAttribute( - actionBinding.btnModerateText.getContext(), - com.google.android.material.R.attr.colorOnSurface - ) - ); - actionBinding.btnModerateText.setText(R.string.mnu_comment_untrash); - } - } - - if (canShowMore()) { - actionBinding.btnMore.setVisibility(View.VISIBLE); - } else { - actionBinding.btnMore.setVisibility(View.GONE); - } - - actionBinding.layoutButtons.setVisibility(View.VISIBLE); - } - - private void performModerateAction( - @NonNull CommentDetailFragmentBinding binding, - @NonNull CommentActionFooterBinding actionBinding, - @NonNull SiteModel site, - @NonNull CommentModel comment, - @Nullable Note note - ) { - if (!isAdded() || !NetworkUtils.checkConnection(getActivity())) { - return; - } - - CommentStatus newStatus = CommentStatus.APPROVED; - CommentStatus currentStatus = CommentStatus.fromString(comment.getStatus()); - if (currentStatus == CommentStatus.APPROVED) { - newStatus = CommentStatus.UNAPPROVED; - } - announceCommentStatusChangeForAccessibility( - currentStatus == CommentStatus.TRASH ? CommentStatus.UNTRASH : newStatus); - - setModerateButtonForStatus(actionBinding, newStatus); - AniUtils.startAnimation(actionBinding.btnModerateIcon, R.anim.notifications_button_scale); - moderateComment(binding, actionBinding, site, comment, note, newStatus); - } - - private void setModerateButtonForStatus( - @NonNull CommentActionFooterBinding actionBinding, - CommentStatus status - ) { - int color; - - if (status == CommentStatus.APPROVED) { - color = ContextExtensionsKt.getColorResIdFromAttribute( - actionBinding.btnModerateText.getContext(), - com.google.android.material.R.attr.colorSecondary - ); - actionBinding.btnModerateText.setText(R.string.comment_status_approved); - actionBinding.btnModerateText.setAlpha(NORMAL_OPACITY); - actionBinding.btnModerateIcon.setAlpha(NORMAL_OPACITY); - } else { - color = ContextExtensionsKt.getColorResIdFromAttribute( - actionBinding.btnModerateText.getContext(), - com.google.android.material.R.attr.colorOnSurface - ); - actionBinding.btnModerateText.setText(R.string.mnu_comment_approve); - actionBinding.btnModerateText.setAlpha(mMediumOpacity); - actionBinding.btnModerateIcon.setAlpha(mMediumOpacity); - } - - ColorUtils.setImageResourceWithTint( - actionBinding.btnModerateIcon, - R.drawable.ic_checkmark_white_24dp, color - ); - actionBinding.btnModerateText.setTextColor(ContextCompat.getColor(requireContext(), color)); - } - - /* - * does user have permission to moderate/reply/spam this comment? - */ - private boolean canModerate() { - return mEnabledActions.contains(EnabledActions.ACTION_APPROVE) - || mEnabledActions.contains(EnabledActions.ACTION_UNAPPROVE); - } - - private boolean canMarkAsSpam() { - return mEnabledActions.contains(EnabledActions.ACTION_SPAM); - } - - private boolean canReply() { - return mEnabledActions.contains(EnabledActions.ACTION_REPLY); - } - - private boolean canTrash() { - return canModerate(); - } - - private boolean canEdit(@NonNull SiteModel site) { - return site.getHasCapabilityEditOthersPosts() || site.isSelfHostedAdmin(); - } - - private boolean canLike(@NonNull SiteModel site) { - return mEnabledActions.contains(EnabledActions.ACTION_LIKE_COMMENT) - && SiteUtils.isAccessedViaWPComRest(site); - } - - /* - * The more button contains controls which only moderates can use - */ - private boolean canShowMore() { - return canModerate(); } /* @@ -1374,8 +703,6 @@ private boolean canShowMore() { */ private void showCommentAsNotification( @NonNull CommentDetailFragmentBinding binding, - @NonNull ReaderIncludeCommentBoxBinding replyBinding, - @NonNull CommentActionFooterBinding actionBinding, @NonNull SiteModel site, @Nullable CommentModel comment, @Nullable Note note @@ -1385,24 +712,6 @@ private void showCommentAsNotification( binding.textContent.setVisibility(View.GONE); - /* - * determine which actions to enable for this comment - if the comment is from this user's - * blog then all actions will be enabled, but they won't be if it's a reply to a comment - * this user made on someone else's blog - */ - if (note != null) { - mEnabledActions = note.getEnabledCommentActions(); - } - - // Set 'Reply to (Name)' in comment reply EditText if it's a reasonable size - if (note != null - && !TextUtils.isEmpty(note.getCommentAuthorName()) - && note.getCommentAuthorName().length() < 28) { - replyBinding.editComment.setHint( - String.format(getString(R.string.comment_reply_to_user), note.getCommentAuthorName()) - ); - } - if (comment != null) { setComment(site, comment); } else if (note != null) { @@ -1410,7 +719,7 @@ private void showCommentAsNotification( } if (note != null) { - addDetailFragment(binding, actionBinding, note.getId()); + addDetailFragment(binding, note.getId()); } requireActivity().invalidateOptionsMenu(); @@ -1418,107 +727,17 @@ private void showCommentAsNotification( private void addDetailFragment( @NonNull CommentDetailFragmentBinding binding, - @NonNull CommentActionFooterBinding actionBinding, String noteId ) { // Now we'll add a detail fragment list FragmentManager fragmentManager = getChildFragmentManager(); FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); mNotificationsDetailListFragment = NotificationsDetailListFragment.newInstance(noteId); - mNotificationsDetailListFragment.setFooterView(actionBinding.layoutButtons); + mNotificationsDetailListFragment.setOnEditCommentListener(mOnActionClickListener); fragmentTransaction.replace(binding.commentContentContainer.getId(), mNotificationsDetailListFragment); fragmentTransaction.commitAllowingStateLoss(); } - private void likeComment( - @NonNull CommentActionFooterBinding actionBinding, - @NonNull SiteModel site, - @NonNull CommentModel comment, - @SuppressWarnings("SameParameterValue") boolean forceLike - ) { - if (!isAdded()) { - return; - } - if (forceLike && actionBinding.btnLike.isActivated()) { - return; - } - - toggleLikeButton(actionBinding, !actionBinding.btnLike.isActivated()); - - ReaderAnim.animateLikeButton(actionBinding.btnLikeIcon, actionBinding.btnLike.isActivated()); - - // Bump analytics - // TODO klymyam remove legacy comment tracking after new comments are shipped and new funnels are made - if (mCommentSource == CommentSource.NOTIFICATION) { - AnalyticsTracker.track( - actionBinding.btnLike.isActivated() ? Stat.NOTIFICATION_LIKED : Stat.NOTIFICATION_UNLIKED - ); - } - if (mCommentSource != null) { - AnalyticsUtils.trackCommentActionWithSiteDetails( - actionBinding.btnLike.isActivated() ? Stat.COMMENT_LIKED : Stat.COMMENT_UNLIKED, - mCommentSource.toAnalyticsCommentActionSource(), - site - ); - } - - if (mNotificationsDetailListFragment != 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 (actionBinding.btnLike.isActivated() - && CommentStatus.fromString(comment.getStatus()) == CommentStatus.UNAPPROVED) { - comment.setStatus(CommentStatus.APPROVED.toString()); - mNotificationsDetailListFragment.refreshBlocksForCommentStatus(CommentStatus.APPROVED); - setModerateButtonForStatus(actionBinding, CommentStatus.APPROVED); - } - } - mCommentsStoreAdapter.dispatch(CommentActionBuilder.newLikeCommentAction( - new RemoteLikeCommentPayload(site, comment, actionBinding.btnLike.isActivated())) - ); - if (mNote != null) { - EventBus.getDefault().postSticky(new NotificationEvents - .OnNoteCommentLikeChanged(mNote, actionBinding.btnLike.isActivated())); - } - - actionBinding.btnLike.announceForAccessibility( - getText(actionBinding.btnLike.isActivated() ? R.string.comment_liked_talkback - : R.string.comment_unliked_talkback) - ); - } - - private void toggleLikeButton( - @NonNull CommentActionFooterBinding actionBinding, - boolean isLiked - ) { - int color; - int drawable; - - if (isLiked) { - color = ContextExtensionsKt.getColorResIdFromAttribute( - actionBinding.btnLikeIcon.getContext(), - com.google.android.material.R.attr.colorSecondary - ); - drawable = R.drawable.ic_star_white_24dp; - actionBinding.btnLikeText.setText(getResources().getString(R.string.mnu_comment_liked)); - actionBinding.btnLike.setActivated(true); - actionBinding.btnLikeText.setAlpha(NORMAL_OPACITY); - actionBinding.btnLikeIcon.setAlpha(NORMAL_OPACITY); - } else { - color = ContextExtensionsKt.getColorResIdFromAttribute( - actionBinding.btnLikeIcon.getContext(), - com.google.android.material.R.attr.colorOnSurface - ); - drawable = R.drawable.ic_star_outline_white_24dp; - actionBinding.btnLikeText.setText(getResources().getString(R.string.reader_label_like)); - actionBinding.btnLike.setActivated(false); - actionBinding.btnLikeText.setAlpha(mMediumOpacity); - actionBinding.btnLikeIcon.setAlpha(mMediumOpacity); - } - - ColorUtils.setImageResourceWithTint(actionBinding.btnLikeIcon, drawable, color); - actionBinding.btnLikeText.setTextColor(ContextCompat.getColor(requireContext(), color)); - } - private void setProgressVisible( @NonNull CommentDetailFragmentBinding binding, boolean visible @@ -1529,8 +748,6 @@ private void setProgressVisible( } private void onCommentModerated( - @NonNull CommentDetailFragmentBinding binding, - @NonNull CommentActionFooterBinding actionBinding, @NonNull SiteModel site, @NonNull CommentModel comment, @Nullable Note note, @@ -1547,7 +764,6 @@ private void onCommentModerated( if (event.isError()) { comment.setStatus(mPreviousStatus); - updateStatusViews(binding, actionBinding, site, comment, note); ToastUtils.showToast(requireActivity(), R.string.error_moderate_comment); } else { reloadComment(site, comment, note); @@ -1556,26 +772,17 @@ private void onCommentModerated( @SuppressWarnings("deprecation") private void onCommentCreated( - @NonNull CommentDetailFragmentBinding binding, - @NonNull ReaderIncludeCommentBoxBinding replyBinding, - @NonNull CommentActionFooterBinding actionBinding, @NonNull SiteModel site, @NonNull CommentModel comment, @Nullable Note note, OnCommentChanged event ) { mIsSubmittingReply = false; - replyBinding.editComment.setEnabled(true); - replyBinding.btnSubmitReply.setVisibility(View.VISIBLE); - replyBinding.progressSubmitComment.setVisibility(View.GONE); - updateStatusViews(binding, actionBinding, site, comment, note); if (event.isError()) { if (isAdded()) { String strUnEscapeHTML = StringEscapeUtils.unescapeHtml4(event.error.message); ToastUtils.showToast(getActivity(), strUnEscapeHTML, ToastUtils.Duration.LONG); - // refocus editor on failure and show soft keyboard - EditTextUtils.showSoftInput(replyBinding.editComment); } return; } @@ -1584,8 +791,6 @@ private void onCommentCreated( if (isAdded()) { ToastUtils.showToast(getActivity(), getString(R.string.note_reply_successful)); - replyBinding.editComment.setText(null); - replyBinding.editComment.getAutoSaveTextHelper().clearSavedText(replyBinding.editComment); } // Self Hosted site does not return a newly created comment, so we need to fetch it manually. @@ -1601,23 +806,7 @@ private void onCommentCreated( // approve the comment if (!(CommentStatus.fromString(comment.getStatus()) == CommentStatus.APPROVED)) { - moderateComment(binding, actionBinding, site, comment, note, CommentStatus.APPROVED); - } - } - - private void onCommentLiked( - @NonNull CommentActionFooterBinding actionBinding, - @Nullable Note note, - OnCommentChanged event - ) { - // send signal for listeners to perform any needed updates - if (note != null) { - EventBus.getDefault().postSticky(new NotificationEvents.NoteLikeOrModerationStatusChanged(note.getId())); - } - - if (event.isError()) { - // Revert button state in case of an error - toggleLikeButton(actionBinding, !actionBinding.btnLike.isActivated()); + moderateComment(site, comment, note, CommentStatus.APPROVED); } } @@ -1634,29 +823,23 @@ public void onCommentChanged(OnCommentChanged event) { (coroutineScope, continuation) -> mLocalCommentCacheUpdateHandler.requestCommentsUpdate(continuation) ); - if (mBinding != null && mReplyBinding != null && mActionBinding != null) { + if (mBinding != null) { setProgressVisible(mBinding, false); if (mSite != null && mComment != null) { // Moderating comment if (event.causeOfChange == CommentAction.PUSH_COMMENT) { - onCommentModerated(mBinding, mActionBinding, mSite, mComment, mNote, event); + onCommentModerated(mSite, mComment, mNote, event); mPreviousStatus = null; return; } // New comment (reply) if (event.causeOfChange == CommentAction.CREATE_NEW_COMMENT) { - onCommentCreated(mBinding, mReplyBinding, mActionBinding, mSite, mComment, mNote, event); + onCommentCreated(mSite, mComment, mNote, event); return; } } - - // Like/Unlike - if (event.causeOfChange == CommentAction.LIKE_COMMENT) { - onCommentLiked(mActionBinding, mNote, event); - return; - } } if (event.isError()) { @@ -1667,168 +850,6 @@ public void onCommentChanged(OnCommentChanged event) { } } - private void announceCommentStatusChangeForAccessibility(CommentStatus newStatus) { - int resId = -1; - switch (newStatus) { - case APPROVED: - resId = R.string.comment_approved_talkback; - break; - case UNAPPROVED: - resId = R.string.comment_unapproved_talkback; - break; - case SPAM: - resId = R.string.comment_spam_talkback; - break; - case TRASH: - resId = R.string.comment_trash_talkback; - break; - case DELETED: - resId = R.string.comment_delete_talkback; - break; - case UNSPAM: - resId = R.string.comment_unspam_talkback; - break; - case UNTRASH: - resId = R.string.comment_untrash_talkback; - break; - case UNREPLIED: - case ALL: - // ignore - break; - default: - AppLog.w(T.COMMENTS, - "AnnounceCommentStatusChangeForAccessibility - Missing switch branch for comment status: " - + newStatus); - } - if (resId != -1 && getView() != null) { - getView().announceForAccessibility(getText(resId)); - } - } - - // Handle More Menu - private void showMoreMenu( - @NonNull CommentDetailFragmentBinding binding, - @NonNull CommentActionFooterBinding actionBinding, - @NonNull SiteModel site, - @NonNull CommentModel comment, - @Nullable Note note, - View view - ) { - androidx.appcompat.widget.PopupMenu morePopupMenu = - new androidx.appcompat.widget.PopupMenu(requireContext(), view); - morePopupMenu.setOnMenuItemClickListener(item -> { - if (item.getItemId() == R.id.action_edit) { - editComment(site); - return true; - } - if (item.getItemId() == R.id.action_trash) { - trashComment(binding, actionBinding, site, comment, note); - return true; - } - if (item.getItemId() == R.id.action_copy_link_address) { - copyCommentLinkAddress(binding, comment); - return true; - } - return false; - }); - - morePopupMenu.inflate(R.menu.menu_comment_more); - - MenuItem trashMenuItem = morePopupMenu.getMenu().findItem(R.id.action_trash); - MenuItem copyLinkAddress = morePopupMenu.getMenu().findItem(R.id.action_copy_link_address); - if (canTrash()) { - CommentStatus commentStatus = CommentStatus.fromString(comment.getStatus()); - if (commentStatus == CommentStatus.TRASH) { - copyLinkAddress.setVisible(false); - trashMenuItem.setTitle(R.string.mnu_comment_delete_permanently); - } else { - trashMenuItem.setTitle(R.string.mnu_comment_trash); - copyLinkAddress.setVisible(commentStatus != CommentStatus.SPAM); - } - } else { - trashMenuItem.setVisible(false); - copyLinkAddress.setVisible(false); - } - - MenuItem editMenuItem = morePopupMenu.getMenu().findItem(R.id.action_edit); - editMenuItem.setVisible(false); - if (canEdit(site)) { - editMenuItem.setVisible(true); - } - morePopupMenu.show(); - } - - private void trashComment( - @NonNull CommentDetailFragmentBinding binding, - @NonNull CommentActionFooterBinding actionBinding, - @NonNull SiteModel site, - @NonNull CommentModel comment, - @Nullable Note note - ) { - if (!isAdded()) { - return; - } - - CommentStatus status = CommentStatus.fromString(comment.getStatus()); - // If the comment status is trash or spam, next deletion is a permanent deletion. - if (status == CommentStatus.TRASH || status == CommentStatus.SPAM) { - AlertDialog.Builder dialogBuilder = new MaterialAlertDialogBuilder(requireActivity()); - 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), - (dialog, whichButton) -> { - moderateComment(binding, actionBinding, site, comment, note, CommentStatus.DELETED); - announceCommentStatusChangeForAccessibility(CommentStatus.DELETED); - }); - dialogBuilder.setNegativeButton( - getResources().getText(R.string.no), - null); - dialogBuilder.setCancelable(true); - dialogBuilder.create().show(); - } else { - moderateComment(binding, actionBinding, site, comment, note, CommentStatus.TRASH); - announceCommentStatusChangeForAccessibility(CommentStatus.TRASH); - } - } - - private void copyCommentLinkAddress( - @NonNull CommentDetailFragmentBinding binding, - @NonNull CommentModel comment - ) { - try { - ClipboardManager clipboard = - (ClipboardManager) requireActivity().getSystemService(Context.CLIPBOARD_SERVICE); - clipboard.setPrimaryClip(ClipData.newPlainText("CommentLinkAddress", comment.getUrl())); - showSnackBar(binding, comment, getString(R.string.comment_q_action_copied_url)); - } catch (Exception e) { - AppLog.e(T.UTILS, e); - showSnackBar(binding, comment, getString(R.string.error_copy_to_clipboard)); - } - } - - private void showSnackBar( - @NonNull CommentDetailFragmentBinding binding, - @NonNull CommentModel comment, - String message - ) { - Snackbar snackBar = WPSnackbar.make(binding.getRoot(), message, Snackbar.LENGTH_LONG) - .setAction(getString(R.string.share_action), - v -> { - try { - Intent intent = new Intent(Intent.ACTION_SEND); - intent.setType("text/plain"); - intent.putExtra(Intent.EXTRA_TEXT, comment.getUrl()); - startActivity(Intent.createChooser(intent, - getString(R.string.comment_share_link_via))); - } catch (ActivityNotFoundException exception) { - ToastUtils.showToast(binding.getRoot().getContext(), - R.string.comment_toast_err_share_intent); - } - }) - .setAnchorView(binding.layoutBottom); - snackBar.show(); - } - @Nullable @Override public View getScrollableViewForUniqueIdProvision() { @@ -1843,8 +864,6 @@ public View getScrollableViewForUniqueIdProvision() { public void onDestroyView() { super.onDestroyView(); mBinding = null; - mReplyBinding = null; - mActionBinding = null; } @Override @@ -1854,4 +873,17 @@ public void onDestroy() { } super.onDestroy(); } + + /** + * Listener for handling comment actions in [NotificationsDetailListFragment] + */ + public interface OnActionClickListener { + void onEditCommentClicked(); + + void onUserInfoClicked(); + + void onShareClicked(); + + void onChangeStatusClicked(); + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailFragmentAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailFragmentAdapter.java index 9b086019716b..0bf0c7b8d930 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailFragmentAdapter.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailFragmentAdapter.java @@ -39,7 +39,7 @@ public class CommentDetailFragmentAdapter extends FragmentStatePagerAdapter { @Override public Fragment getItem(int position) { final CommentModel comment = getComment(position); - return CommentDetailFragment.newInstance(mSite, comment); + return SiteCommentDetailFragment.newInstance(mSite, comment); } void onNewItems(CommentList commentList) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailViewModel.kt new file mode 100644 index 000000000000..70d5c45b04e8 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/CommentDetailViewModel.kt @@ -0,0 +1,88 @@ +@file:Suppress("DEPRECATION") + +package org.wordpress.android.ui.comments + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import org.wordpress.android.fluxc.generated.CommentActionBuilder +import org.wordpress.android.fluxc.model.CommentModel +import org.wordpress.android.fluxc.model.CommentStatus +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.comments.CommentsMapper +import org.wordpress.android.fluxc.store.CommentStore +import org.wordpress.android.fluxc.store.CommentStore.RemoteLikeCommentPayload +import org.wordpress.android.fluxc.store.CommentsStore +import org.wordpress.android.models.Note +import org.wordpress.android.modules.BG_THREAD +import org.wordpress.android.ui.comments.unified.CommentsStoreAdapter +import org.wordpress.android.ui.notifications.NotificationEvents.OnNoteCommentLikeChanged +import org.wordpress.android.util.EventBusWrapper +import org.wordpress.android.viewmodel.ScopedViewModel +import javax.inject.Inject +import javax.inject.Named + +@HiltViewModel +class CommentDetailViewModel @Inject constructor( + @Named(BG_THREAD) bgDispatcher: CoroutineDispatcher, + private val commentsStore: CommentsStore, + private val commentsStoreAdapter: CommentsStoreAdapter, + private val eventBusWrapper: EventBusWrapper, + private val commentsMapper: CommentsMapper +) : ScopedViewModel(bgDispatcher) { + private val _updatedComment = MutableLiveData() + val updatedComment: LiveData = _updatedComment + + /** + * Like or unlike a comment + * @param comment the comment to like or unlike + * @param site the site the comment belongs to + * @param note the note the comment belongs to, non-null if the comment is from a notification + */ + fun likeComment(comment: CommentModel, site: SiteModel, note: Note? = null) = launch { + val liked = comment.iLike.not() + comment.apply { iLike = liked } + .let { _updatedComment.postValue(it) } + + commentsStoreAdapter.dispatch( + CommentActionBuilder.newLikeCommentAction( + RemoteLikeCommentPayload(site, comment, liked) + ) + ) + + note?.let { + eventBusWrapper.postSticky(OnNoteCommentLikeChanged(note, liked)) + } + } + + /** + * Dispatch a moderation action to the server + */ + fun dispatchModerationAction(site: SiteModel, comment: CommentModel, status: CommentStatus) { + comment.apply { this.status = status.toString() } + commentsStoreAdapter.dispatch( + if (status == CommentStatus.DELETED) { + // For deletion, we need to dispatch a specific action. + CommentActionBuilder.newDeleteCommentAction(CommentStore.RemoteCommentPayload(site, comment)) + } else { + // Actual moderation (push the modified comment). + CommentActionBuilder.newPushCommentAction(CommentStore.RemoteCommentPayload(site, comment)) + } + ) + + _updatedComment.postValue(comment) + } + + /** + * Fetch the latest comment from the server + * @param site the site the comment belongs to + * @param remoteCommentId the remote ID of the comment to fetch + */ + fun fetchComment(site: SiteModel, remoteCommentId: Long) = launch { + val result = commentsStore.fetchComment(site, remoteCommentId, null) + result.data?.comments?.firstOrNull()?.let { + _updatedComment.postValue(commentsMapper.commentEntityToLegacyModel(it)) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/ModerationBottomSheetDialogFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/ModerationBottomSheetDialogFragment.kt new file mode 100644 index 000000000000..ccaae0bd08bd --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/ModerationBottomSheetDialogFragment.kt @@ -0,0 +1,105 @@ +package org.wordpress.android.ui.comments + +import android.os.Bundle +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.core.view.isVisible +import com.google.android.material.R +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import kotlinx.parcelize.Parcelize +import org.wordpress.android.databinding.CommentModerationBinding +import org.wordpress.android.util.extensions.getParcelableCompat + +class ModerationBottomSheetDialogFragment : BottomSheetDialogFragment() { + private var binding: CommentModerationBinding? = null + private val state by lazy { + arguments?.getParcelableCompat(KEY_STATE) + ?: throw IllegalArgumentException("CommentState not provided") + } + + var onApprovedClicked = {} + var onPendingClicked = {} + var onSpamClicked = {} + var onTrashClicked = {} + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) = + CommentModerationBinding.inflate(inflater, container, false).apply { + binding = this + }.root + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // define the peekHeight to avoid it expanding partially + dialog?.setOnShowListener { dialogInterface -> + val sheetDialog = dialogInterface as? BottomSheetDialog + + val bottomSheet = sheetDialog?.findViewById( + R.id.design_bottom_sheet + ) as? FrameLayout + + bottomSheet?.let { + val behavior = BottomSheetBehavior.from(it) + val metrics = resources.displayMetrics + behavior.peekHeight = metrics.heightPixels + } + } + + binding?.setupLayout() + } + + private fun CommentModerationBinding.setupLayout() { + // handle visibilities + buttonApprove.isVisible = state.canModerate + buttonPending.isVisible = state.canModerate + buttonSpam.isVisible = state.canMarkAsSpam + buttonTrash.isVisible = state.canTrash + + // handle clicks + buttonApprove.setOnClickListener { + onApprovedClicked() + dismiss() + } + buttonPending.setOnClickListener { + onPendingClicked() + dismiss() + } + buttonSpam.setOnClickListener { + onSpamClicked() + dismiss() + } + buttonTrash.setOnClickListener { + onTrashClicked() + dismiss() + } + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + companion object { + const val TAG = "ModerationBottomSheetDialogFragment" + private const val KEY_STATE = "state" + fun newInstance(state: CommentState) = ModerationBottomSheetDialogFragment() + .apply { + arguments = Bundle().apply { putParcelable(KEY_STATE, state) } + } + } + + /** + * For handling the UI state of the comment moderation bottom sheet + */ + @Parcelize + data class CommentState( + val canModerate: Boolean, + val canTrash: Boolean, + val canMarkAsSpam: Boolean, + ) : Parcelable +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/NotificationCommentDetailFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/NotificationCommentDetailFragment.kt new file mode 100644 index 000000000000..64f63e9480cd --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/NotificationCommentDetailFragment.kt @@ -0,0 +1,103 @@ +package org.wordpress.android.ui.comments + +import android.os.Bundle +import android.view.View +import androidx.core.view.isGone +import org.wordpress.android.R +import org.wordpress.android.analytics.AnalyticsTracker +import org.wordpress.android.analytics.AnalyticsTracker.Stat +import org.wordpress.android.datasets.NotificationsTable +import org.wordpress.android.fluxc.tools.FormattableRangeType +import org.wordpress.android.models.Note +import org.wordpress.android.ui.comments.unified.CommentIdentifier +import org.wordpress.android.ui.comments.unified.CommentSource +import org.wordpress.android.ui.engagement.BottomSheetUiState +import org.wordpress.android.ui.reader.tracker.ReaderTracker.Companion.SOURCE_NOTIF_COMMENT_USER_PROFILE +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.ToastUtils +import java.util.EnumSet + +/** + * Used when called from notification list for a comment notification + * [CommentDetailFragment] is too big to be reused + * It'd be better to have multiple fragments for different sources for different purposes + */ +class NotificationCommentDetailFragment : SharedCommentDetailFragment() { + override val enabledActions: EnumSet + get() = note.enabledCommentActions + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (savedInstanceState?.getString(KEY_NOTE_ID) != null) { + handleNote(savedInstanceState.getString(KEY_NOTE_ID)!!) + } else { + handleNote(requireArguments().getString(KEY_NOTE_ID)!!) + } + + viewModel.fetchComment(site, note.commentId) + } + + override fun sendLikeCommentEvent(liked: Boolean) { + super.sendLikeCommentEvent(liked) + // it should also track the notification liked/unliked event + AnalyticsTracker.track( + if (liked) Stat.NOTIFICATION_LIKED else Stat.NOTIFICATION_UNLIKED + ) + } + + override fun getUserProfileUiState(): BottomSheetUiState.UserProfileUiState { + val user = mContentMapper.mapToFormattableContentList(note.body.toString()) + .find { FormattableRangeType.fromString(it.type) == FormattableRangeType.USER } + + return BottomSheetUiState.UserProfileUiState( + userAvatarUrl = note.iconURL, + blavatarUrl = "", + userName = user?.text ?: getString(R.string.anonymous), + userLogin = mComment?.authorEmail.orEmpty(), + userBio = "", + siteTitle = user?.meta?.titles?.home ?: getString(R.string.user_profile_untitled_site), + siteUrl = user?.ranges?.firstOrNull()?.url.orEmpty(), + siteId = user?.meta?.ids?.site ?: 0L, + blogPreviewSource = SOURCE_NOTIF_COMMENT_USER_PROFILE + ) + } + + override fun getCommentIdentifier(): CommentIdentifier = + CommentIdentifier.NotificationCommentIdentifier(note.id, note.commentId); + + override fun handleHeaderVisibility() { + mBinding?.headerView?.isGone = true + } + + private fun handleNote(noteId: String) { + val note = NotificationsTable.getNoteById(noteId) + if (note == null) { + // this should not happen + AppLog.e(AppLog.T.NOTIFS, "Note could not be found.") + ToastUtils.showToast(activity, R.string.error_notification_open) + requireActivity().finish() + } else { + mNote = note + mSite = mSiteStore.getSiteBySiteId(note.siteId.toLong()) + if (mSite == null) { + // This should not exist, we should clean that screen so a note without a site/comment can be displayed + mSite = createDummyWordPressComSite(note.siteId.toLong()) + } + if (mBinding != null) { + showComment(mBinding!!, mSite!!, mComment, note) + } + } + } + + companion object { + @JvmStatic + fun newInstance(noteId: String): NotificationCommentDetailFragment { + val fragment = NotificationCommentDetailFragment() + val args = Bundle() + args.putSerializable(KEY_MODE, CommentSource.NOTIFICATION) + args.putString(KEY_NOTE_ID, noteId) + fragment.arguments = args + return fragment + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/SharedCommentDetailFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/SharedCommentDetailFragment.kt new file mode 100644 index 000000000000..219e737399ce --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/SharedCommentDetailFragment.kt @@ -0,0 +1,230 @@ +@file:Suppress("DEPRECATION") + +package org.wordpress.android.ui.comments + +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.fragment.app.viewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.gravatar.AvatarQueryOptions +import com.gravatar.AvatarUrl +import com.gravatar.types.Email +import dagger.hilt.android.AndroidEntryPoint +import org.wordpress.android.R +import org.wordpress.android.analytics.AnalyticsTracker +import org.wordpress.android.databinding.CommentApprovedBinding +import org.wordpress.android.databinding.CommentPendingBinding +import org.wordpress.android.databinding.CommentTrashBinding +import org.wordpress.android.fluxc.model.CommentModel +import org.wordpress.android.fluxc.model.CommentStatus +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.models.Note +import org.wordpress.android.models.Note.EnabledActions +import org.wordpress.android.ui.comments.CommentExtension.canMarkAsSpam +import org.wordpress.android.ui.comments.CommentExtension.canModerate +import org.wordpress.android.ui.comments.CommentExtension.canTrash +import org.wordpress.android.ui.main.utils.MeGravatarLoader +import org.wordpress.android.util.SiteUtils +import org.wordpress.android.util.WPAvatarUtils +import org.wordpress.android.util.analytics.AnalyticsUtils +import org.wordpress.android.util.image.ImageType +import java.util.EnumSet +import javax.inject.Inject + +/** + * Used when we want to write Kotlin in [CommentDetailFragment] + * Move converted code to this class + */ +@AndroidEntryPoint +abstract class SharedCommentDetailFragment : CommentDetailFragment() { + // it will be non-null after view created when users from a notification + // it will be null when users from comment list + protected val note: Note + get() = mNote!! + + // it will be non-null after view created when users from comment list + // it will be non-null in a different time point when users from a notification + protected val comment: CommentModel + get() = mComment!! + + protected val site: SiteModel + get() = mSite!! + + protected val viewModel: CommentDetailViewModel by viewModels() + + @Inject + lateinit var accountStore: AccountStore + + @Inject + lateinit var meGravatarLoader: MeGravatarLoader + + /* + * 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 + */ + abstract val enabledActions: EnumSet + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.updatedComment.observe(viewLifecycleOwner) { + mComment = it + updateModerationStatus() + } + } + + override fun updateModerationStatus() { + // reset visibilities + mBinding?.layoutCommentPending?.root?.isVisible = false + mBinding?.layoutCommentApproved?.root?.isVisible = false + mBinding?.layoutCommentTrash?.root?.isVisible = false + + val commentStatus = CommentStatus.fromString(comment.status) + when (commentStatus) { + CommentStatus.APPROVED -> mBinding?.layoutCommentApproved?.bindApprovedView() + CommentStatus.UNAPPROVED -> mBinding?.layoutCommentPending?.bindPendingView() + CommentStatus.SPAM, CommentStatus.TRASH -> mBinding?.layoutCommentTrash?.bindTrashView() + CommentStatus.DELETED, + CommentStatus.ALL, + CommentStatus.UNREPLIED, + CommentStatus.UNSPAM, + CommentStatus.UNTRASH -> { + // do nothing + } + } + } + + private fun CommentTrashBinding.bindTrashView() { + root.isVisible = true + buttonDeleteComment.setOnClickListener { + showDeleteCommentDialog() + } + } + + private fun showDeleteCommentDialog() { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.delete) + .setMessage(R.string.dlg_sure_to_delete_comment) + .setPositiveButton(R.string.yes) { _, _ -> + moderateComment(site, comment, mNote, CommentStatus.DELETED) + } + .setNegativeButton(R.string.no) { _, _ -> } + .show() + } + + private fun CommentPendingBinding.bindPendingView() { + root.isVisible = true + buttonApproveComment.setOnClickListener { + moderateComment(site, comment, mNote, CommentStatus.APPROVED) + } + textMoreOptions.setOnClickListener { showModerationBottomSheet() } + } + + private fun CommentApprovedBinding.bindApprovedView() { + root.isVisible = true + meGravatarLoader.load( + newAvatarUploaded = false, + avatarUrl = meGravatarLoader.constructGravatarUrl(accountStore.account.avatarUrl), + imageView = replyAvatar, + imageType = ImageType.USER, + injectFilePath = null, + ) + + if (comment.iLike) { + handleLikeCommentView( + textLikeComment, + R.color.inline_action_filled, + R.drawable.star_filled, + R.string.comment_liked + ) + } else { + handleLikeCommentView( + textLikeComment, + R.color.menu_more, + R.drawable.star_empty, + R.string.like_comment + ) + } + + textLikeComment.setOnClickListener { + viewModel.likeComment(comment, site) + sendLikeCommentEvent(comment.iLike.not()) + } + cardReply.setOnClickListener { } + textReply.text = getString(R.string.comment_reply_to_user, comment.authorName) + } + + /** + * Handle like comment text based on the liked status + */ + private fun handleLikeCommentView(textView: TextView, colorId: Int, drawableId: Int, stringId: Int) { + textView.text = getString(stringId) + val color = ContextCompat.getColor(textView.context, colorId) + textView.setTextColor(color) + ContextCompat.getDrawable(textView.context, drawableId) + ?.apply { setTint(color) } + ?.let { textView.setCompoundDrawablesWithIntrinsicBounds(it, null, null, null) } + } + + protected open fun sendLikeCommentEvent(liked: Boolean) { + AnalyticsUtils.trackCommentActionWithSiteDetails( + if (liked) AnalyticsTracker.Stat.COMMENT_LIKED else AnalyticsTracker.Stat.COMMENT_UNLIKED, + mCommentSource.toAnalyticsCommentActionSource(), + site + ) + } + + override fun showModerationBottomSheet() { + ModerationBottomSheetDialogFragment.newInstance( + ModerationBottomSheetDialogFragment.CommentState( + canModerate = enabledActions.canModerate(), + canMarkAsSpam = enabledActions.canMarkAsSpam(), + canTrash = enabledActions.canTrash(), + ) + ).apply { + onApprovedClicked = { moderateComment(site, comment, mNote, CommentStatus.APPROVED) } + onPendingClicked = { moderateComment(site, comment, mNote, CommentStatus.UNAPPROVED) } + onTrashClicked = { moderateComment(site, comment, mNote, CommentStatus.TRASH) } + onSpamClicked = { moderateComment(site, comment, mNote, CommentStatus.SPAM) } + }.show(childFragmentManager, ModerationBottomSheetDialogFragment.TAG) + } +} + +object CommentExtension { + /** + * Have permission to moderate/reply/spam this comment + */ + fun EnumSet.canModerate(): Boolean = contains(EnabledActions.ACTION_APPROVE) + || contains(EnabledActions.ACTION_UNAPPROVE) + + fun EnumSet.canMarkAsSpam(): Boolean = contains(EnabledActions.ACTION_SPAM) + + fun EnumSet.canReply(): Boolean = contains(EnabledActions.ACTION_REPLY) + + fun EnumSet.canTrash(): Boolean = canModerate() + + fun canEdit(site: SiteModel): Boolean = site.hasCapabilityEditOthersPosts || site.isSelfHostedAdmin + + fun EnumSet.canLike(site: SiteModel): Boolean = + (contains(EnabledActions.ACTION_LIKE_COMMENT) && SiteUtils.isAccessedViaWPComRest(site)) + + fun CommentModel.getAvatarUrl(size: Int): String = when { + authorProfileImageUrl != null -> WPAvatarUtils.rewriteAvatarUrl( + authorProfileImageUrl!!, + size + ) + + authorEmail != null -> AvatarUrl( + Email(authorEmail!!), + AvatarQueryOptions(size, null, null, null) + ).url().toString() + + else -> "" + } + + fun CommentModel.liked() = this.iLike +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/SiteCommentDetailFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/SiteCommentDetailFragment.kt new file mode 100644 index 000000000000..eae88beca29d --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/SiteCommentDetailFragment.kt @@ -0,0 +1,80 @@ +package org.wordpress.android.ui.comments + +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.CommentModel +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.models.Note.EnabledActions +import org.wordpress.android.ui.comments.CommentExtension.getAvatarUrl +import org.wordpress.android.ui.comments.unified.CommentIdentifier +import org.wordpress.android.ui.comments.unified.CommentSource +import org.wordpress.android.ui.engagement.BottomSheetUiState +import org.wordpress.android.ui.reader.tracker.ReaderTracker +import java.util.EnumSet + +/** + * Used when called from comment list + * [CommentDetailFragment] is too big to be reused + * It'd be better to have multiple fragments for different sources for different purposes + */ +class SiteCommentDetailFragment : SharedCommentDetailFragment() { + override val enabledActions: EnumSet + get() = EnumSet.allOf(EnabledActions::class.java) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (savedInstanceState != null) { + handleComment(savedInstanceState.getLong(KEY_COMMENT_ID), savedInstanceState.getInt(KEY_SITE_LOCAL_ID)) + } else { + handleComment(requireArguments().getLong(KEY_COMMENT_ID), requireArguments().getInt(KEY_SITE_LOCAL_ID)) + } + viewModel.fetchComment(site, comment.remoteCommentId) + } + + override fun getUserProfileUiState(): BottomSheetUiState.UserProfileUiState = + BottomSheetUiState.UserProfileUiState( + userAvatarUrl = comment.getAvatarUrl( + resources.getDimensionPixelSize(R.dimen.avatar_sz_large) + ), + blavatarUrl = "", + userName = comment.authorName ?: getString(R.string.anonymous), + userLogin = comment.authorEmail.orEmpty(), + // keep them empty because there's no data for displaying on UI + userBio = "", + siteTitle = "", + siteUrl = "", + siteId = 0L, + blogPreviewSource = ReaderTracker.SOURCE_SITE_COMMENTS_USER_PROFILE + ) + + override fun getCommentIdentifier(): CommentIdentifier = + CommentIdentifier.SiteCommentIdentifier(comment.id, comment.remoteCommentId) + + + override fun handleHeaderVisibility() { + mBinding?.headerView?.isVisible = true + } + + private fun handleComment(commentRemoteId: Long, siteLocalId: Int) { + val site = mSiteStore.getSiteByLocalId(siteLocalId) + if (site != null) { + setComment(site, mCommentsStoreAdapter.getCommentBySiteAndRemoteId(site, commentRemoteId)) + } + } + + companion object { + @JvmStatic + fun newInstance( + site: SiteModel, + commentModel: CommentModel + ): SiteCommentDetailFragment = SiteCommentDetailFragment().apply { + arguments = Bundle().apply { + putSerializable(KEY_MODE, CommentSource.SITE_COMMENTS) + putInt(KEY_SITE_LOCAL_ID, site.id) + putLong(KEY_COMMENT_ID, commentModel.remoteCommentId) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/CommentActionPopupHandler.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/CommentActionPopupHandler.kt new file mode 100644 index 000000000000..8725bc84d8a5 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/CommentActionPopupHandler.kt @@ -0,0 +1,40 @@ +@file:Suppress("DEPRECATION") + +package org.wordpress.android.ui.comments.unified + +import android.view.LayoutInflater +import android.view.View +import android.widget.PopupWindow +import org.wordpress.android.R +import org.wordpress.android.databinding.CommentActionsBinding +import org.wordpress.android.ui.comments.CommentDetailFragment + +object CommentActionPopupHandler { + @JvmStatic + fun show(anchorView: View, listener: CommentDetailFragment.OnActionClickListener?) { + val popupWindow = PopupWindow(anchorView.context, null, R.style.WordPress) + popupWindow.isOutsideTouchable = true + popupWindow.elevation = anchorView.context.resources.getDimension(R.dimen.popup_over_toolbar_elevation) + popupWindow.contentView = CommentActionsBinding + .inflate(LayoutInflater.from(anchorView.context)) + .apply { + textUserInfo.setOnClickListener { + listener?.onUserInfoClicked() + popupWindow.dismiss() + } + textShare.setOnClickListener { + listener?.onShareClicked() + popupWindow.dismiss() + } + textEditComment.setOnClickListener { + listener?.onEditCommentClicked() + popupWindow.dismiss() + } + textChangeStatus.setOnClickListener { + listener?.onChangeStatusClicked() + popupWindow.dismiss() + } + }.root + popupWindow.showAsDropDown(anchorView) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/engagement/BottomSheetUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/engagement/BottomSheetUiState.kt index 45b0fc146195..f9b7f68a3931 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/engagement/BottomSheetUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/engagement/BottomSheetUiState.kt @@ -1,6 +1,9 @@ package org.wordpress.android.ui.engagement +import java.io.Serializable + sealed class BottomSheetUiState { + @Suppress("SerialVersionUIDInSerializableClass") data class UserProfileUiState( val userAvatarUrl: String, val blavatarUrl: String, @@ -10,9 +13,8 @@ sealed class BottomSheetUiState { val siteTitle: String, val siteUrl: String, val siteId: Long, - val onSiteClickListener: ((siteId: Long, siteUrl: String, source: String) -> Unit)? = null, val blogPreviewSource: String - ) : BottomSheetUiState() { + ) : BottomSheetUiState(), Serializable { val hasSiteUrl: Boolean = siteUrl.isNotBlank() } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/engagement/EngagedPeopleAdapter.kt b/WordPress/src/main/java/org/wordpress/android/ui/engagement/EngagedPeopleAdapter.kt index e41fcb620a1b..5dc5fefedd6b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/engagement/EngagedPeopleAdapter.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/engagement/EngagedPeopleAdapter.kt @@ -12,7 +12,8 @@ import org.wordpress.android.viewmodel.ResourceProvider class EngagedPeopleAdapter constructor( private val imageManager: ImageManager, - private val resourceProvider: ResourceProvider + private val resourceProvider: ResourceProvider, + private val type: ListScenarioType ) : Adapter() { private var itemsList = listOf() @@ -36,8 +37,8 @@ class EngagedPeopleAdapter constructor( override fun onBindViewHolder(holder: EngagedPeopleViewHolder, position: Int) { val item = itemsList[position] when (item) { - is LikedItem -> (holder as LikedItemViewHolder).bind(item) - is Liker -> (holder as LikerViewHolder).bind(item) + is LikedItem -> (holder as LikedItemViewHolder).bind(item, type) + is Liker -> (holder as LikerViewHolder).bind(item, position) is NextLikesPageLoader -> (holder as NextPageLoadViewHolder).bind(item) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/engagement/EngagedPeopleListFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/engagement/EngagedPeopleListFragment.kt index 3c5937d693f3..86a01ede483c 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/engagement/EngagedPeopleListFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/engagement/EngagedPeopleListFragment.kt @@ -6,15 +6,18 @@ import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle.State import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton import com.google.android.material.snackbar.Snackbar import org.wordpress.android.R import org.wordpress.android.WordPress import org.wordpress.android.ui.ActionableEmptyView +import org.wordpress.android.ui.ActivityLauncher import org.wordpress.android.ui.WPWebViewActivity import org.wordpress.android.ui.engagement.BottomSheetAction.HideBottomSheet import org.wordpress.android.ui.engagement.BottomSheetAction.ShowBottomSheet @@ -73,6 +76,9 @@ class EngagedPeopleListFragment : Fragment() { private lateinit var loadingView: View private lateinit var rootView: View private lateinit var emptyView: ActionableEmptyView + private lateinit var divider: View + private lateinit var shareButton: MaterialButton + private val listScenario by lazy { requireNotNull(arguments?.getParcelableCompat(KEY_LIST_SCENARIO)) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -93,8 +99,8 @@ class EngagedPeopleListFragment : Fragment() { recycler = view.findViewById(R.id.recycler) loadingView = view.findViewById(R.id.loading_view) emptyView = view.findViewById(R.id.actionable_empty_view) - - val listScenario = requireNotNull(arguments?.getParcelableCompat(KEY_LIST_SCENARIO)) + divider = view.findViewById(R.id.divider) + shareButton = view.findViewById(R.id.button_share_post) val layoutManager = LinearLayoutManager(activity) @@ -105,16 +111,17 @@ class EngagedPeopleListFragment : Fragment() { recycler.layoutManager = layoutManager userProfileViewModel.onBottomSheetAction.observeEvent(viewLifecycleOwner) { state -> - var bottomSheet = childFragmentManager.findFragmentByTag(USER_PROFILE_BOTTOM_SHEET_TAG) + var bottomSheet = childFragmentManager.findFragmentByTag(UserProfileBottomSheetFragment.TAG) as? UserProfileBottomSheetFragment when (state) { ShowBottomSheet -> { if (bottomSheet == null) { bottomSheet = UserProfileBottomSheetFragment.newInstance(USER_PROFILE_VM_KEY) - bottomSheet.show(childFragmentManager, USER_PROFILE_BOTTOM_SHEET_TAG) + bottomSheet.show(childFragmentManager, UserProfileBottomSheetFragment.TAG) } } + HideBottomSheet -> { bottomSheet?.apply { this.dismiss() } } @@ -164,10 +171,12 @@ class EngagedPeopleListFragment : Fragment() { readerTracker ) } + is PreviewSiteByUrl -> { val url = event.siteUrl openUrl(this, url, event.source) } + is PreviewCommentInReader -> { ReaderActivityLauncher.showReaderComments( this, @@ -177,11 +186,13 @@ class EngagedPeopleListFragment : Fragment() { event.source.sourceDescription ) } + is PreviewPostInReader -> { ReaderActivityLauncher.showReaderPostDetail(this, event.siteId, event.postId) } + is OpenUserProfileBottomSheet -> { - userProfileViewModel.onBottomSheetOpen(event.userProfile, event.onClick, event.source) + userProfileViewModel.onBottomSheetOpen(event.userProfile, event.source) } } @@ -217,6 +228,7 @@ class EngagedPeopleListFragment : Fragment() { serviceRequest.postId, null ) + is RequestComment -> ReaderCommentService.startServiceForComment( this, serviceRequest.siteId, @@ -239,7 +251,8 @@ class EngagedPeopleListFragment : Fragment() { private fun setupAdapter(items: List) { val adapter = recycler.adapter as? EngagedPeopleAdapter ?: EngagedPeopleAdapter( imageManager, - resourceProvider + resourceProvider, + listScenario.type ).also { recycler.adapter = it } @@ -247,6 +260,17 @@ class EngagedPeopleListFragment : Fragment() { val recyclerViewState = recycler.layoutManager?.onSaveInstanceState() adapter.loadData(items) recycler.layoutManager?.onRestoreInstanceState(recyclerViewState) + + val visible = listScenario.type == ListScenarioType.LOAD_POST_LIKES + divider.isVisible = visible + shareButton.isVisible = visible + shareButton.setOnClickListener { + ActivityLauncher.openShareIntent( + it.context, + listScenario.postUrl, + listScenario.postTitle + ) + } } private fun showSnackbar(holder: SnackbarMessageHolder) { @@ -280,8 +304,6 @@ class EngagedPeopleListFragment : Fragment() { private const val KEY_LIST_SCENARIO = "list_scenario" private const val KEY_LIST_STATE = "list_state" - private const val USER_PROFILE_BOTTOM_SHEET_TAG = "USER_PROFILE_BOTTOM_SHEET_TAG" - @JvmStatic fun newInstance(listScenario: ListScenario): EngagedPeopleListFragment { val args = Bundle() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/engagement/LikedItemViewHolder.kt b/WordPress/src/main/java/org/wordpress/android/ui/engagement/LikedItemViewHolder.kt index d86bbfe11fc2..5ae3910c5776 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/engagement/LikedItemViewHolder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/engagement/LikedItemViewHolder.kt @@ -5,6 +5,8 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView +import androidx.cardview.widget.CardView +import androidx.core.view.isGone import org.wordpress.android.R import org.wordpress.android.ui.engagement.AuthorName.AuthorNameCharSequence import org.wordpress.android.ui.engagement.AuthorName.AuthorNameString @@ -19,25 +21,23 @@ class LikedItemViewHolder( parent: ViewGroup, private val imageManager: ImageManager ) : EngagedPeopleViewHolder(parent, R.layout.note_block_header) { - private val name = itemView.findViewById(R.id.header_user) private val snippet = itemView.findViewById(R.id.header_snippet) private val avatar = itemView.findViewById(R.id.header_avatar) - private val rootView = itemView.findViewById(R.id.header_root_view) + private val rootView = itemView.findViewById(R.id.header_root_view) - fun bind(likedItem: LikedItem) { + fun bind(likedItem: LikedItem, type: ListScenarioType) { val authorName = when (val author = likedItem.author) { is AuthorNameString -> author.nameString is AuthorNameCharSequence -> author.nameCharSequence } - this.name.text = authorName this.snippet.text = likedItem.postOrCommentText val avatarUrl = WPAvatarUtils.rewriteAvatarUrl( likedItem.authorAvatarUrl, - rootView.context.resources.getDimensionPixelSize(R.dimen.avatar_sz_small) + rootView.context.resources.getDimensionPixelSize(R.dimen.avatar_sz_extra_small) ) - + avatar.isGone = type == ListScenarioType.LOAD_POST_LIKES imageManager.loadIntoCircle(this.avatar, ImageType.AVATAR_WITH_BACKGROUND, avatarUrl) if (!TextUtils.isEmpty(likedItem.authorPreferredSiteUrl) || likedItem.authorPreferredSiteId > 0) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/engagement/LikerViewHolder.kt b/WordPress/src/main/java/org/wordpress/android/ui/engagement/LikerViewHolder.kt index e4fc6b615215..7270449dad0e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/engagement/LikerViewHolder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/engagement/LikerViewHolder.kt @@ -22,7 +22,17 @@ class LikerViewHolder( private val likerAvatar = itemView.findViewById(R.id.user_avatar) private val likerRootView = itemView.findViewById(R.id.liker_root_view) - fun bind(liker: Liker) { + fun bind(liker: Liker, position: Int) { + val isFirstLiker = position == 1 + itemView.layoutParams = (itemView.layoutParams as ViewGroup.MarginLayoutParams).apply { + topMargin = resourceProvider.getDimensionPixelSize( + if (isFirstLiker) { + R.dimen.margin_small + } else { + R.dimen.margin_none + } + ) + } this.likerName.text = liker.name this.likerLogin.text = if (liker.login.isNotBlank()) { resourceProvider.getString(R.string.at_username, liker.login) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/engagement/ListScenario.kt b/WordPress/src/main/java/org/wordpress/android/ui/engagement/ListScenario.kt index aff925151f7a..33580e39da66 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/engagement/ListScenario.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/engagement/ListScenario.kt @@ -13,6 +13,8 @@ data class ListScenario( val postOrCommentId: Long, val commentPostId: Long = 0, val commentSiteUrl: String, + val postTitle: String, + val postUrl: String, val headerData: HeaderData ) : Parcelable diff --git a/WordPress/src/main/java/org/wordpress/android/ui/engagement/ListScenarioUtils.kt b/WordPress/src/main/java/org/wordpress/android/ui/engagement/ListScenarioUtils.kt index 50f5221b7183..16550d33c791 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/engagement/ListScenarioUtils.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/engagement/ListScenarioUtils.kt @@ -42,7 +42,7 @@ class ListScenarioUtils @Inject constructor( imageManager, notificationsUtilsWrapper ) - headerNoteBlock.setIsComment(note.isCommentType) + headerNoteBlock.setReplyToComment(note.isCommentReplyType) val spannable: Spannable = notificationsUtilsWrapper.getSpannableContentForRanges(headerNoteBlock.getHeader(0)) val spans = spannable.getSpans( @@ -60,6 +60,8 @@ class ListScenarioUtils @Inject constructor( postOrCommentId = if (note.isPostLikeType) note.postId.toLong() else note.commentId, commentPostId = if (note.isCommentLikeType) note.postId.toLong() else 0L, commentSiteUrl = if (note.isCommentLikeType) note.url else "", + postUrl = note.url, + postTitle = note.title, headerData = HeaderData( authorName = AuthorNameCharSequence(spannable), snippetText = headerNoteBlock.getHeader(1).getTextOrEmpty(), diff --git a/WordPress/src/main/java/org/wordpress/android/ui/engagement/UserProfileBottomSheetFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/engagement/UserProfileBottomSheetFragment.kt index b0c6e5a13d83..5745e9d1cf03 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/engagement/UserProfileBottomSheetFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/engagement/UserProfileBottomSheetFragment.kt @@ -1,15 +1,17 @@ +@file:Suppress("DEPRECATION") + package org.wordpress.android.ui.engagement import android.content.Context import android.content.DialogInterface +import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.FrameLayout -import android.widget.ImageView -import android.widget.TextView import androidx.core.view.ViewCompat +import androidx.core.view.isGone import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStoreOwner import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -17,15 +19,19 @@ import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment import org.wordpress.android.R import org.wordpress.android.WordPress +import org.wordpress.android.databinding.UserProfileBottomSheetBinding +import org.wordpress.android.ui.WPWebViewActivity import org.wordpress.android.ui.engagement.BottomSheetUiState.UserProfileUiState +import org.wordpress.android.ui.reader.ReaderActivityLauncher +import org.wordpress.android.ui.reader.tracker.ReaderTracker import org.wordpress.android.ui.utils.UiHelpers import org.wordpress.android.util.PhotonUtils -import org.wordpress.android.util.PhotonUtils.Quality.HIGH import org.wordpress.android.util.UrlUtils import org.wordpress.android.util.WPAvatarUtils +import org.wordpress.android.util.WPUrlUtils +import org.wordpress.android.util.analytics.AnalyticsUtilsWrapper import org.wordpress.android.util.image.ImageManager -import org.wordpress.android.util.image.ImageType.AVATAR_WITH_BACKGROUND -import org.wordpress.android.util.image.ImageType.BLAVATAR +import org.wordpress.android.util.image.ImageType import org.wordpress.android.viewmodel.ResourceProvider import javax.inject.Inject import com.google.android.material.R as MaterialR @@ -43,41 +49,71 @@ class UserProfileBottomSheetFragment : BottomSheetDialogFragment() { @Inject lateinit var resourceProvider: ResourceProvider - private lateinit var viewModel: UserProfileViewModel - - companion object { - const val USER_PROFILE_VIEW_MODEL_KEY = "user_profile_view_model_key" - - fun newInstance(viewModelKey: String): UserProfileBottomSheetFragment { - val fragment = UserProfileBottomSheetFragment() - val bundle = Bundle() + @Inject + lateinit var analyticsUtilsWrapper: AnalyticsUtilsWrapper - bundle.putString(USER_PROFILE_VIEW_MODEL_KEY, viewModelKey) + @Inject + lateinit var readerTracker: ReaderTracker + + private var viewModel: UserProfileViewModel? = null + private var binding: UserProfileBottomSheetBinding? = null + private val state by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requireArguments().getSerializable(USER_PROFILE_STATE, UserProfileUiState::class.java) + } else { + requireArguments().getSerializable(USER_PROFILE_STATE) as? UserProfileUiState + } + } - fragment.arguments = bundle + companion object { + private const val USER_PROFILE_VIEW_MODEL_KEY = "user_profile_view_model_key" + private const val USER_PROFILE_STATE = "user_profile_state" + + const val TAG = "USER_PROFILE_BOTTOM_SHEET_TAG" + + /** + * For displaying the user profile when users are from Likes + */ + fun newInstance(viewModelKey: String) = UserProfileBottomSheetFragment() + .apply { + arguments = Bundle().apply { + putString(USER_PROFILE_VIEW_MODEL_KEY, viewModelKey) + } + } - return fragment - } + /** + * For displaying the user profile when users are from Comments or Notifications + */ + @JvmStatic + fun newInstance(state: UserProfileUiState) = UserProfileBottomSheetFragment() + .apply { + arguments = Bundle().apply { + putSerializable(USER_PROFILE_STATE, state) + } + } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.user_profile_bottom_sheet, container) - } + ): View = UserProfileBottomSheetBinding.inflate(inflater, container, false) + .apply { binding = this } + .root override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val vmKey = requireArguments().getString(USER_PROFILE_VIEW_MODEL_KEY)!! + val vmKey = requireArguments().getString(USER_PROFILE_VIEW_MODEL_KEY) ViewCompat.setAccessibilityPaneTitle(view, getString(R.string.user_profile_bottom_sheet_description)) - viewModel = ViewModelProvider(parentFragment as ViewModelStoreOwner, viewModelFactory) - .get(vmKey, UserProfileViewModel::class.java) + vmKey?.let { + viewModel = ViewModelProvider(parentFragment as ViewModelStoreOwner, viewModelFactory) + .get(vmKey, UserProfileViewModel::class.java) + initObservers() + } - initObservers(view) + state?.let { binding?.setup(it) } dialog?.setOnShowListener { dialogInterface -> val sheetDialog = dialogInterface as? BottomSheetDialog @@ -94,68 +130,81 @@ class UserProfileBottomSheetFragment : BottomSheetDialogFragment() { } } - private fun initObservers(view: View) { - val userAvatar = view.findViewById(R.id.user_avatar) - val blavatar = view.findViewById(R.id.user_site_blavatar) - val userName = view.findViewById(R.id.user_name) - val userLogin = view.findViewById(R.id.user_login) - val userBio = view.findViewById(R.id.user_bio) - val siteTitle = view.findViewById(R.id.site_title) - val siteUrl = view.findViewById(R.id.site_url) - val siteSectionHeader = view.findViewById(R.id.site_section_header) - val siteData = view.findViewById(R.id.site_data) - - viewModel.bottomSheetUiState.observe(viewLifecycleOwner, { state -> + private fun initObservers() { + viewModel?.bottomSheetUiState?.observe(viewLifecycleOwner) { state -> when (state) { is UserProfileUiState -> { - val avatarSz = resourceProvider.getDimensionPixelSize(R.dimen.user_profile_bottom_sheet_avatar_sz) - val blavatarSz = resourceProvider.getDimensionPixelSize(R.dimen.avatar_sz_medium) - - imageManager.loadIntoCircle( - userAvatar, - AVATAR_WITH_BACKGROUND, - WPAvatarUtils.rewriteAvatarUrl(state.userAvatarUrl, avatarSz) - ) - userName.text = state.userName - userLogin.text = if (state.userLogin.isNotBlank()) { - getString(R.string.at_username, state.userLogin) - } else { - "" - } - if (state.userBio.isNotBlank()) { - userBio.text = state.userBio - userBio.visibility = View.VISIBLE - } else { - userBio.visibility = View.GONE - } - - imageManager.load( - blavatar, - BLAVATAR, - PhotonUtils.getPhotonImageUrl(state.blavatarUrl, blavatarSz, blavatarSz, HIGH) - ) - - if (state.hasSiteUrl) { - siteTitle.text = state.siteTitle - siteUrl.text = UrlUtils.getHost(state.siteUrl) - siteData.setOnClickListener { - state.onSiteClickListener?.invoke( - state.siteId, - state.siteUrl, - state.blogPreviewSource - ) - } - siteSectionHeader.visibility = View.VISIBLE - blavatar.visibility = View.VISIBLE - siteData.visibility = View.VISIBLE - } else { - siteSectionHeader.visibility = View.GONE - blavatar.visibility = View.GONE - siteData.visibility = View.GONE - } + binding?.setup(state) } } - }) + } + } + + private fun UserProfileBottomSheetBinding.setup(state: UserProfileUiState) { + val avatarSz = + resourceProvider.getDimensionPixelSize(R.dimen.user_profile_bottom_sheet_avatar_sz) + val blavatarSz = resourceProvider.getDimensionPixelSize(R.dimen.avatar_sz_medium) + + imageManager.loadIntoCircle( + userAvatar, + ImageType.AVATAR_WITH_BACKGROUND, + WPAvatarUtils.rewriteAvatarUrl(state.userAvatarUrl, avatarSz) + ) + userName.text = state.userName + userLogin.text = state.userLogin.ifBlank { + userLogin.isGone = true + "" + } + if (state.userBio.isNotBlank()) { + userBio.text = state.userBio + userBio.visibility = View.VISIBLE + } else { + userBio.visibility = View.GONE + } + + imageManager.load( + userSiteBlavatar, + ImageType.BLAVATAR, + PhotonUtils.getPhotonImageUrl(state.blavatarUrl, blavatarSz, blavatarSz, PhotonUtils.Quality.HIGH) + ) + + if (state.hasSiteUrl) { + siteTitle.text = state.siteTitle + siteUrl.text = UrlUtils.getHost(state.siteUrl) + siteData.setOnClickListener { + if (state.siteId <= 0L && state.siteUrl.isNotEmpty()) { + openSiteUrl(state.siteUrl, state.blogPreviewSource) + } else { + openSiteId(state.siteId, state.blogPreviewSource) + } + } + siteSectionHeader.visibility = View.VISIBLE + userSiteBlavatar.visibility = View.VISIBLE + siteData.visibility = View.VISIBLE + } else { + siteSectionHeader.visibility = View.GONE + userSiteBlavatar.visibility = View.GONE + siteData.visibility = View.GONE + } + } + + private fun openSiteId(siteId: Long, source: String) { + ReaderActivityLauncher.showReaderBlogPreview( + context, + siteId, + false, + source, + readerTracker + ) + } + + private fun openSiteUrl(url: String, source: String) { + analyticsUtilsWrapper.trackBlogPreviewedByUrl(source) + if (WPUrlUtils.isWordPressCom(url)) { + WPWebViewActivity.openUrlByUsingGlobalWPCOMCredentials(context, url) + } else { + WPWebViewActivity.openURL(context, url) + } } override fun onAttach(context: Context) { @@ -165,6 +214,11 @@ class UserProfileBottomSheetFragment : BottomSheetDialogFragment() { override fun onCancel(dialog: DialogInterface) { super.onCancel(dialog) - viewModel.onBottomSheetCancelled() + viewModel?.onBottomSheetCancelled() + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/engagement/UserProfileViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/engagement/UserProfileViewModel.kt index fab1bced1f12..a2b6e94b8c19 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/engagement/UserProfileViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/engagement/UserProfileViewModel.kt @@ -28,7 +28,6 @@ class UserProfileViewModel @Inject constructor( fun onBottomSheetOpen( userProfile: UserProfile, - onClick: ((siteId: Long, siteUrl: String, source: String) -> Unit)?, source: EngagementNavigationSource? ) { _bottomSheetUiState.value = with(userProfile) { @@ -36,7 +35,7 @@ class UserProfileViewModel @Inject constructor( userAvatarUrl = userAvatarUrl, blavatarUrl = blavatarUrl, userName = userName, - userLogin = userLogin, + userLogin = if (userLogin.isNotEmpty()) "@$userLogin" else userLogin, userBio = userBio, siteTitle = if (siteTitle.isBlank()) { resourceProvider.getString(R.string.user_profile_untitled_site) @@ -45,7 +44,6 @@ class UserProfileViewModel @Inject constructor( }, siteUrl = siteUrl, siteId = siteId, - onSiteClickListener = onClick, blogPreviewSource = source?.let { when (it) { LIKE_NOTIFICATION_LIST -> ReaderTracker.SOURCE_NOTIF_LIKE_LIST_USER_PROFILE diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java index eae822dfde03..9e074da25dec 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java @@ -1072,7 +1072,7 @@ public Unit invoke() { NotificationsListFragment.NOTE_INSTANT_REPLY_EXTRA, false); NotificationsListFragment.openNoteForReply(WPMainActivity.this, noteId, - shouldShowKeyboard, null, Filter.ALL, true); + shouldShowKeyboard, Filter.ALL, true); return null; } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/MilestoneDetailFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/MilestoneDetailFragment.kt new file mode 100644 index 000000000000..3a95eb6d53a2 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/MilestoneDetailFragment.kt @@ -0,0 +1,228 @@ +package org.wordpress.android.ui.notifications + +import android.os.Bundle +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.fragment.app.ListFragment +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONException +import org.wordpress.android.R +import org.wordpress.android.WordPress +import org.wordpress.android.datasets.NotificationsTable +import org.wordpress.android.models.Note +import org.wordpress.android.modules.IO_THREAD +import org.wordpress.android.modules.UI_THREAD +import org.wordpress.android.ui.ScrollableViewInitializedListener +import org.wordpress.android.ui.ViewPagerFragment.Companion.restoreOriginalViewId +import org.wordpress.android.ui.ViewPagerFragment.Companion.setUniqueIdToView +import org.wordpress.android.ui.notifications.adapters.NoteBlockAdapter +import org.wordpress.android.ui.notifications.blocks.MilestoneNoteBlock +import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T.NOTIFS +import org.wordpress.android.util.ToastUtils +import org.wordpress.android.util.image.ImageManager +import javax.inject.Inject +import javax.inject.Named + +class MilestoneDetailFragment : ListFragment(), NotificationFragment { + private var restoredListPosition = 0 + private var notification: Note? = null + private var rootLayout: LinearLayout? = null + private var restoredNoteId: String? = null + private var noteBlockAdapter: NoteBlockAdapter? = null + + @Inject + lateinit var imageManager: ImageManager + + @Inject + lateinit var notificationsUtilsWrapper: NotificationsUtilsWrapper + + @Inject + @Named(IO_THREAD) + lateinit var ioDispatcher: CoroutineDispatcher + + @Inject + @Named(UI_THREAD) + lateinit var mainDispatcher: CoroutineDispatcher + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + (requireActivity().application as WordPress).component().inject(this) + if (savedInstanceState != null && savedInstanceState.containsKey(KEY_NOTE_ID)) { + restoredNoteId = savedInstanceState.getString(KEY_NOTE_ID) + restoredListPosition = savedInstanceState.getInt(KEY_LIST_POSITION, 0) + } else { + arguments?.let { + setNote(it.getString(KEY_NOTE_ID)) { + reloadNoteBlocks() + } + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + notification?.let { + outState.putString(KEY_NOTE_ID, it.id) + outState.putInt(KEY_LIST_POSITION, listView.firstVisiblePosition) + } ?: run { + // This is done so the fragments pre-loaded by the view pager can store the already rescued restoredNoteId + if (!TextUtils.isEmpty(restoredNoteId)) { + outState.putString(KEY_NOTE_ID, restoredNoteId) + } + } + + super.onSaveInstanceState(outState) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.notifications_fragment_detail_list, container, false) + rootLayout = view.findViewById(R.id.notifications_list_root) + return view + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val listView = listView + listView.divider = null + listView.dividerHeight = 0 + listView.setHeaderDividersEnabled(false) + } + + override fun onResume() { + super.onResume() + setUniqueIdToView(listView) + if (activity is ScrollableViewInitializedListener) { + (activity as ScrollableViewInitializedListener).onScrollableViewInitialized(listView.id) + } + + // Set the note if we retrieved the noteId from savedInstanceState + if (!TextUtils.isEmpty(restoredNoteId)) { + setNote(restoredNoteId) { + reloadNoteBlocks() + restoredNoteId = null + } + } + } + + override fun onPause() { + restoreOriginalViewId(listView) + super.onPause() + } + + private fun setNote(noteId: String?, onNoteSet: (() -> Unit)? = null) { + if (noteId == null) { + showErrorToastAndFinish() + return + } + lifecycleScope.launch(ioDispatcher) { + val note: Note? = NotificationsTable.getNoteById(noteId) + withContext(mainDispatcher) { + if (note == null) { + showErrorToastAndFinish() + } else { + notification = note + onNoteSet?.invoke() + } + } + } + } + + private fun showErrorToastAndFinish() { + AppLog.e(NOTIFS, "Note could not be found.") + activity?.let { + ToastUtils.showToast(activity, R.string.error_notification_open) + it.finish() + } + } + + private fun reloadNoteBlocks() { + lifecycleScope.launch(ioDispatcher) { + notification?.let { note -> + val noteBlocks = noteBlocksLoader.loadNoteBlocks(note) + withContext(mainDispatcher) { + noteBlocksLoader.handleNoteBlocks(noteBlocks) + } + } + } + } + + private val mOnNoteBlockTextClickListener = NoteBlockTextClickListener(this, notification) + + // Loop through the 'body' items in this note, and create blocks for each. + private val noteBlocksLoader = object { + private fun addNotesBlock(noteList: MutableList, bodyArray: JSONArray) { + var i = 0 + while (i < bodyArray.length()) { + try { + val noteObject = notificationsUtilsWrapper + .mapJsonToFormattableContent(bodyArray.getJSONObject(i)) + + val noteBlock = MilestoneNoteBlock( + noteObject, imageManager, notificationsUtilsWrapper, + mOnNoteBlockTextClickListener + ) + preloadImage(noteBlock) + noteList.add(noteBlock) + } catch (e: JSONException) { + AppLog.e(NOTIFS, "Error parsing milestone note data.") + } + i++ + } + } + + private fun preloadImage(noteBlock: MilestoneNoteBlock) { + if (noteBlock.hasImageMediaItem()) { + noteBlock.noteMediaItem?.url?.let { + imageManager.preload(requireContext(), it) + } + } + } + + fun loadNoteBlocks(note: Note): List { + val bodyArray = note.body + val noteList: MutableList = ArrayList() + + if (bodyArray.length() > 0) { + addNotesBlock(noteList, bodyArray) + } + return noteList + } + + fun handleNoteBlocks(noteList: List?) { + if (!isAdded || noteList == null) { + return + } + if (noteBlockAdapter == null) { + noteBlockAdapter = NoteBlockAdapter(requireContext(), noteList) + listAdapter = noteBlockAdapter + } else { + noteBlockAdapter?.setNoteList(noteList) + } + if (restoredListPosition > 0) { + listView.setSelectionFromTop(restoredListPosition, 0) + restoredListPosition = 0 + } + } + } + + companion object { + private const val KEY_NOTE_ID = "noteId" + private const val KEY_LIST_POSITION = "listPosition" + + @JvmStatic + fun newInstance(noteId: String?): MilestoneDetailFragment { + val fragment = MilestoneDetailFragment() + val bundle = Bundle().apply { putString(KEY_NOTE_ID, noteId) } + fragment.arguments = bundle + return fragment + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NoteBlockTextClickListener.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NoteBlockTextClickListener.kt new file mode 100644 index 000000000000..235905e1d52a --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NoteBlockTextClickListener.kt @@ -0,0 +1,139 @@ +@file:Suppress("DEPRECATION") + +package org.wordpress.android.ui.notifications + +import android.text.TextUtils +import android.view.View +import androidx.fragment.app.Fragment +import org.wordpress.android.datasets.ReaderPostTable +import org.wordpress.android.fluxc.tools.FormattableRangeType +import org.wordpress.android.models.Note +import org.wordpress.android.ui.comments.CommentDetailFragment +import org.wordpress.android.ui.comments.unified.CommentActionPopupHandler +import org.wordpress.android.ui.notifications.blocks.NoteBlock +import org.wordpress.android.ui.notifications.blocks.NoteBlockClickableSpan +import org.wordpress.android.ui.reader.ReaderActivityLauncher +import org.wordpress.android.ui.reader.comments.ThreadedCommentsActionSource +import org.wordpress.android.ui.reader.utils.ReaderUtils + +class NoteBlockTextClickListener( + val fragment: Fragment, + val notification: Note?, + private val onActionClickListener: CommentDetailFragment.OnActionClickListener? = null +) : NoteBlock.OnNoteBlockTextClickListener { + override fun onNoteBlockTextClicked(clickedSpan: NoteBlockClickableSpan?) { + if (!fragment.isAdded || fragment.activity !is NotificationsDetailActivity) { + return + } + clickedSpan?.let { handleNoteBlockSpanClick(fragment.activity as NotificationsDetailActivity, it) } + } + + override fun showDetailForNoteIds() { + if (!fragment.isAdded || notification == null || fragment.activity !is NotificationsDetailActivity) { + return + } + val detailActivity = fragment.activity as NotificationsDetailActivity + + requireNotNull(notification).let { note -> + if (note.isCommentReplyType || !note.isCommentType && note.commentId > 0) { + val commentId = if (note.isCommentReplyType) note.parentCommentId else note.commentId + + // show comments list if it exists in the reader + if (ReaderUtils.postAndCommentExists(note.siteId.toLong(), note.postId.toLong(), commentId)) { + detailActivity.showReaderCommentsList(note.siteId.toLong(), note.postId.toLong(), commentId) + } else { + detailActivity.showWebViewActivityForUrl(note.url) + } + } else if (note.isFollowType) { + detailActivity.showBlogPreviewActivity(note.siteId.toLong(), note.isFollowType) + } else { + // otherwise, load the post in the Reader + detailActivity.showPostActivity(note.siteId.toLong(), note.postId.toLong()) + } + } + } + + override fun showReaderPostComments() { + if (!fragment.isAdded || notification == null || notification.commentId == 0L) { + return + } + + requireNotNull(notification).let { note -> + fragment.context?.let { nonNullContext -> + ReaderActivityLauncher.showReaderComments( + nonNullContext, note.siteId.toLong(), note.postId.toLong(), + note.commentId, + ThreadedCommentsActionSource.COMMENT_NOTIFICATION.sourceDescription + ) + } + } + } + + override fun showSitePreview(siteId: Long, siteUrl: String?) { + if (!fragment.isAdded || notification == null || fragment.activity !is NotificationsDetailActivity) { + return + } + val detailActivity = fragment.activity as NotificationsDetailActivity + if (siteId != 0L) { + detailActivity.showBlogPreviewActivity(siteId, notification.isFollowType) + } else if (!TextUtils.isEmpty(siteUrl)) { + detailActivity.showWebViewActivityForUrl(siteUrl) + } + } + + override fun showActionPopup(view: View) { + CommentActionPopupHandler.show(view, onActionClickListener) + } + + fun handleNoteBlockSpanClick( + activity: NotificationsDetailActivity, + clickedSpan: NoteBlockClickableSpan + ) { + when (clickedSpan.rangeType) { + FormattableRangeType.SITE -> + // Show blog preview + activity.showBlogPreviewActivity(clickedSpan.id, notification?.isFollowType) + + FormattableRangeType.USER -> + // Show blog preview + activity.showBlogPreviewActivity(clickedSpan.siteId, notification?.isFollowType) + + FormattableRangeType.POST -> + // Show post detail + activity.showPostActivity(clickedSpan.siteId, clickedSpan.id) + + FormattableRangeType.COMMENT -> + // Load the comment in the reader list if it exists, otherwise show a webview + if (ReaderUtils.postAndCommentExists( + clickedSpan.siteId, clickedSpan.postId, + clickedSpan.id + ) + ) { + activity.showReaderCommentsList( + clickedSpan.siteId, clickedSpan.postId, + clickedSpan.id + ) + } else { + activity.showWebViewActivityForUrl(clickedSpan.url) + } + + FormattableRangeType.SCAN -> activity.showScanActivityForSite(clickedSpan.siteId) + FormattableRangeType.STAT, FormattableRangeType.FOLLOW -> + // We can open native stats if the site is a wpcom or Jetpack sites + activity.showStatsActivityForSite(clickedSpan.siteId, clickedSpan.rangeType) + + FormattableRangeType.LIKE -> if (ReaderPostTable.postExists(clickedSpan.siteId, clickedSpan.id)) { + activity.showReaderPostLikeUsers(clickedSpan.siteId, clickedSpan.id) + } else { + activity.showPostActivity(clickedSpan.siteId, clickedSpan.id) + } + + FormattableRangeType.REWIND_DOWNLOAD_READY -> activity.showBackupForSite(clickedSpan.siteId) + else -> + // We don't know what type of id this is, let's see if it has a URL and push a webview + if (!TextUtils.isEmpty(clickedSpan.url)) { + activity.showWebViewActivityForUrl(clickedSpan.url) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationFragment.java index 45ee5ecaed94..0fca088cf1c4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationFragment.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationFragment.java @@ -7,17 +7,10 @@ */ package org.wordpress.android.ui.notifications; -import androidx.annotation.Nullable; - import org.wordpress.android.models.Note; public interface NotificationFragment { interface OnPostClickListener { void onPostClicked(Note note, long remoteBlogId, int postId); } - - @Nullable - Note getNote(); - - void setNote(String noteId); } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailActivity.java index 612d64f1fe8c..c95c62b91051 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailActivity.java @@ -33,6 +33,7 @@ import org.wordpress.android.fluxc.store.SiteStore; import org.wordpress.android.fluxc.tools.FormattableRangeType; import org.wordpress.android.models.Note; +import org.wordpress.android.models.NoteExtensions; import org.wordpress.android.push.GCMMessageHandler; import org.wordpress.android.ui.ActivityLauncher; import org.wordpress.android.ui.CollapseFullScreenDialogFragment; @@ -40,7 +41,7 @@ import org.wordpress.android.ui.ScrollableViewInitializedListener; import org.wordpress.android.ui.WPWebViewActivity; import org.wordpress.android.ui.comments.CommentActions; -import org.wordpress.android.ui.comments.CommentDetailFragment; +import org.wordpress.android.ui.comments.NotificationCommentDetailFragment; import org.wordpress.android.ui.engagement.EngagedPeopleListFragment; import org.wordpress.android.ui.engagement.ListScenarioUtils; import org.wordpress.android.ui.notifications.adapters.Filter; @@ -77,7 +78,6 @@ import javax.inject.Inject; import static org.wordpress.android.models.Note.NOTE_COMMENT_LIKE_TYPE; -import static org.wordpress.android.models.Note.NOTE_COMMENT_TYPE; import static org.wordpress.android.models.Note.NOTE_FOLLOW_TYPE; import static org.wordpress.android.models.Note.NOTE_LIKE_TYPE; import static org.wordpress.android.ui.notifications.services.NotificationsUpdateServiceStarter.IS_TAPPED_ON_NOTIFICATION; @@ -355,20 +355,27 @@ private void setActionBarTitleForNote(Note note) { case NOTE_LIKE_TYPE: title = getString(R.string.like); break; - case NOTE_COMMENT_TYPE: - title = getString(R.string.comment); - break; } } - // Force change the Action Bar title for 'new_post' notifications. + // Force change the Action Bar title for 'new_post' and 'comment' notifications. if (note.isNewPostType()) { title = getString(R.string.reader_title_post_detail); + } else if (note.isCommentType()) { + title = ""; + } + + if (NoteExtensions.isAchievement(note)) { + title = ""; } getSupportActionBar().setTitle(title); // important for accessibility - talkback - setTitle(getString(R.string.notif_detail_screen_title, title)); + if (note.isCommentType()) { + setTitle(getString(R.string.notif_detail_screen_title, getString(R.string.comment))); + } else { + setTitle(getString(R.string.notif_detail_screen_title, title)); + } } } @@ -402,11 +409,10 @@ private Fragment createDetailFragmentForNote(@NonNull Note note) { // show comment detail for comment notifications boolean isInstantReply = getIntent().getBooleanExtra(NotificationsListFragment.NOTE_INSTANT_REPLY_EXTRA, false); - fragment = CommentDetailFragment.newInstance(note.getId(), - getIntent().getStringExtra(NotificationsListFragment.NOTE_PREFILLED_REPLY_EXTRA)); + fragment = NotificationCommentDetailFragment.newInstance(note.getId()); if (isInstantReply) { - ((CommentDetailFragment) fragment).enableShouldFocusReplyField(); + ((NotificationCommentDetailFragment) fragment).enableShouldFocusReplyField(); } } else if (note.isAutomattcherType()) { // show reader post detail for automattchers about posts - note that comment @@ -425,6 +431,8 @@ private Fragment createDetailFragmentForNote(@NonNull Note note) { note.getSiteId(), note.getPostId() ); + } else if (NoteExtensions.isAchievement(note)) { + fragment = MilestoneDetailFragment.newInstance(note.getId()); } else { if (mLikesEnhancementsFeatureConfig.isEnabled() && note.isLikeType()) { fragment = EngagedPeopleListFragment.newInstance( @@ -560,7 +568,10 @@ public void onModerateCommentForNote(@NonNull Note note, @NonNull CommentStatus resultIntent.putExtra(NotificationsListFragment.NOTE_MODERATE_STATUS_EXTRA, newStatus.toString()); setResult(RESULT_OK, resultIntent); - finish(); + + if (newStatus == CommentStatus.DELETED) { + finish(); + } } @SuppressWarnings("unused") diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailListFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailListFragment.kt index b96defd836b5..db32e4a6f641 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailListFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailListFragment.kt @@ -15,7 +15,6 @@ import android.widget.LinearLayout import android.widget.ListView import androidx.fragment.app.ListFragment import androidx.lifecycle.lifecycleScope -import com.airbnb.lottie.LottieAnimationView import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -28,21 +27,13 @@ import org.wordpress.android.datasets.ReaderCommentTable import org.wordpress.android.datasets.ReaderPostTable import org.wordpress.android.fluxc.model.CommentStatus import org.wordpress.android.fluxc.tools.FormattableContent -import org.wordpress.android.fluxc.tools.FormattableRangeType.COMMENT -import org.wordpress.android.fluxc.tools.FormattableRangeType.FOLLOW -import org.wordpress.android.fluxc.tools.FormattableRangeType.LIKE -import org.wordpress.android.fluxc.tools.FormattableRangeType.POST -import org.wordpress.android.fluxc.tools.FormattableRangeType.REWIND_DOWNLOAD_READY -import org.wordpress.android.fluxc.tools.FormattableRangeType.SCAN -import org.wordpress.android.fluxc.tools.FormattableRangeType.SITE -import org.wordpress.android.fluxc.tools.FormattableRangeType.STAT -import org.wordpress.android.fluxc.tools.FormattableRangeType.USER import org.wordpress.android.models.Note import org.wordpress.android.modules.IO_THREAD import org.wordpress.android.modules.UI_THREAD import org.wordpress.android.ui.ScrollableViewInitializedListener import org.wordpress.android.ui.ViewPagerFragment.Companion.restoreOriginalViewId import org.wordpress.android.ui.ViewPagerFragment.Companion.setUniqueIdToView +import org.wordpress.android.ui.comments.CommentDetailFragment import org.wordpress.android.ui.engagement.ListScenarioUtils import org.wordpress.android.ui.notifications.adapters.NoteBlockAdapter import org.wordpress.android.ui.notifications.blocks.BlockType @@ -53,15 +44,11 @@ import org.wordpress.android.ui.notifications.blocks.GeneratedNoteBlock import org.wordpress.android.ui.notifications.blocks.HeaderNoteBlock import org.wordpress.android.ui.notifications.blocks.NoteBlock import org.wordpress.android.ui.notifications.blocks.NoteBlock.OnNoteBlockTextClickListener -import org.wordpress.android.ui.notifications.blocks.NoteBlockClickableSpan import org.wordpress.android.ui.notifications.blocks.UserNoteBlock import org.wordpress.android.ui.notifications.blocks.UserNoteBlock.OnGravatarClickedListener import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper -import org.wordpress.android.ui.reader.ReaderActivityLauncher import org.wordpress.android.ui.reader.actions.ReaderPostActions -import org.wordpress.android.ui.reader.comments.ThreadedCommentsActionSource.COMMENT_NOTIFICATION import org.wordpress.android.ui.reader.services.comment.ReaderCommentService -import org.wordpress.android.ui.reader.utils.ReaderUtils import org.wordpress.android.util.AppLog import org.wordpress.android.util.AppLog.T.NOTIFS import org.wordpress.android.util.ToastUtils @@ -73,6 +60,7 @@ import javax.inject.Inject import javax.inject.Named class NotificationsDetailListFragment : ListFragment(), NotificationFragment { + private var onActionClickListener: CommentDetailFragment.OnActionClickListener? = null private var restoredListPosition = 0 private var notification: Note? = null private var rootLayout: LinearLayout? = null @@ -107,18 +95,21 @@ class NotificationsDetailListFragment : ListFragment(), NotificationFragment { // See WordPress.deferredInit() restoredNoteId = savedInstanceState.getString(KEY_NOTE_ID) restoredListPosition = savedInstanceState.getInt(KEY_LIST_POSITION, 0) + } else { + arguments?.let { + setNote(it.getString(KEY_NOTE_ID)) + } } } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val view = inflater.inflate(R.layout.notifications_fragment_detail_list, container, false) - rootLayout = view.findViewById(R.id.notifications_list_root) as LinearLayout + rootLayout = view.findViewById(R.id.notifications_list_root)!! return view } - @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") - override fun onActivityCreated(bundle: Bundle?) { - super.onActivityCreated(bundle) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) val listView = listView listView.divider = null listView.dividerHeight = 0 @@ -142,17 +133,9 @@ class NotificationsDetailListFragment : ListFragment(), NotificationFragment { reloadNoteBlocks() restoredNoteId = null } - if (note == null) { + if (notification == null) { showErrorToastAndFinish() } - - val animation = view?.findViewById(R.id.confetti) - if (note?.isViewMilestoneType == true) { - animation?.visibility = View.VISIBLE - animation?.playAnimation() - } else { - animation?.visibility = View.GONE - } } override fun onPause() { @@ -162,11 +145,7 @@ class NotificationsDetailListFragment : ListFragment(), NotificationFragment { super.onPause() } - override fun getNote(): Note? { - return notification - } - - override fun setNote(noteId: String?) { + private fun setNote(noteId: String?) { if (noteId == null) { showErrorToastAndFinish() return @@ -216,124 +195,23 @@ class NotificationsDetailListFragment : ListFragment(), NotificationFragment { this.footerView = footerView } - private val mOnNoteBlockTextClickListener: OnNoteBlockTextClickListener = object : OnNoteBlockTextClickListener { - override fun onNoteBlockTextClicked(clickedSpan: NoteBlockClickableSpan) { - if (!isAdded || activity !is NotificationsDetailActivity) { - return - } - handleNoteBlockSpanClick(activity as NotificationsDetailActivity, clickedSpan) - } - - override fun showDetailForNoteIds() { - if (!isAdded || notification == null || activity !is NotificationsDetailActivity) { - return - } - val detailActivity = activity as NotificationsDetailActivity - - requireNotNull(notification).let { note -> - if (note.isCommentReplyType || !note.isCommentType && note.commentId > 0) { - val commentId = if (note.isCommentReplyType) note.parentCommentId else note.commentId - - // show comments list if it exists in the reader - if (ReaderUtils.postAndCommentExists(note.siteId.toLong(), note.postId.toLong(), commentId)) { - detailActivity.showReaderCommentsList(note.siteId.toLong(), note.postId.toLong(), commentId) - } else { - detailActivity.showWebViewActivityForUrl(note.url) - } - } else if (note.isFollowType) { - detailActivity.showBlogPreviewActivity(note.siteId.toLong(), note.isFollowType) - } else { - // otherwise, load the post in the Reader - detailActivity.showPostActivity(note.siteId.toLong(), note.postId.toLong()) - } - } - } - - override fun showReaderPostComments() { - if (!isAdded || notification == null || notification!!.commentId == 0L) { - return - } - - requireNotNull(notification).let { note -> - context?.let { nonNullContext -> - ReaderActivityLauncher.showReaderComments( - nonNullContext, note.siteId.toLong(), note.postId.toLong(), - note.commentId, - COMMENT_NOTIFICATION.sourceDescription - ) - } - } - } + private val mOnNoteBlockTextClickListener by lazy { + NoteBlockTextClickListener(this, notification, onActionClickListener) + } - override fun showSitePreview(siteId: Long, siteUrl: String) { - if (!isAdded || notification == null || activity !is NotificationsDetailActivity) { + private val mOnGravatarClickedListener = object : OnGravatarClickedListener { + override fun onGravatarClicked(siteId: Long, userId: Long, siteUrl: String?) { + if (!isAdded || activity !is NotificationsDetailActivity) { return } val detailActivity = activity as NotificationsDetailActivity - if (siteId != 0L) { - detailActivity.showBlogPreviewActivity(siteId, note?.isFollowType) - } else if (!TextUtils.isEmpty(siteUrl)) { + if (siteId == 0L && !TextUtils.isEmpty(siteUrl)) { detailActivity.showWebViewActivityForUrl(siteUrl) - } - } - - fun handleNoteBlockSpanClick( - activity: NotificationsDetailActivity, - clickedSpan: NoteBlockClickableSpan - ) { - when (clickedSpan.rangeType) { - SITE -> - // Show blog preview - activity.showBlogPreviewActivity(clickedSpan.id, note?.isFollowType) - USER -> - // Show blog preview - activity.showBlogPreviewActivity(clickedSpan.siteId, note?.isFollowType) - POST -> - // Show post detail - activity.showPostActivity(clickedSpan.siteId, clickedSpan.id) - COMMENT -> - // Load the comment in the reader list if it exists, otherwise show a webview - if (ReaderUtils.postAndCommentExists( - clickedSpan.siteId, clickedSpan.postId, - clickedSpan.id - ) - ) { - activity.showReaderCommentsList( - clickedSpan.siteId, clickedSpan.postId, - clickedSpan.id - ) - } else { - activity.showWebViewActivityForUrl(clickedSpan.url) - } - SCAN -> activity.showScanActivityForSite(clickedSpan.siteId) - STAT, FOLLOW -> - // We can open native stats if the site is a wpcom or Jetpack sites - activity.showStatsActivityForSite(clickedSpan.siteId, clickedSpan.rangeType) - LIKE -> if (ReaderPostTable.postExists(clickedSpan.siteId, clickedSpan.id)) { - activity.showReaderPostLikeUsers(clickedSpan.siteId, clickedSpan.id) - } else { - activity.showPostActivity(clickedSpan.siteId, clickedSpan.id) - } - REWIND_DOWNLOAD_READY -> activity.showBackupForSite(clickedSpan.siteId) - else -> - // We don't know what type of id this is, let's see if it has a URL and push a webview - if (!TextUtils.isEmpty(clickedSpan.url)) { - activity.showWebViewActivityForUrl(clickedSpan.url) - } + } else if (siteId != 0L) { + detailActivity.showBlogPreviewActivity(siteId, notification?.isFollowType) } } } - private val mOnGravatarClickedListener = OnGravatarClickedListener { siteId, _, siteUrl -> - if (!isAdded || activity !is NotificationsDetailActivity) { - return@OnGravatarClickedListener - } - val detailActivity = activity as NotificationsDetailActivity - if (siteId == 0L && !TextUtils.isEmpty(siteUrl)) { - detailActivity.showWebViewActivityForUrl(siteUrl) - } else if (siteId != 0L) { - detailActivity.showBlogPreviewActivity(siteId, note?.isFollowType) - } - } private data class ManageUserBlockResults(val index: Int, val noteBlock: NoteBlock, val pingbackUrl: String?) @@ -352,7 +230,7 @@ class NotificationsDetailListFragment : ListFragment(), NotificationFragment { imageManager, notificationsUtilsWrapper ) - headerNoteBlock.setIsComment(note.isCommentType) + headerNoteBlock.setReplyToComment(note.isCommentReplyType) noteList.add(headerNoteBlock) } @@ -454,13 +332,14 @@ class NotificationsDetailListFragment : ListFragment(), NotificationFragment { if (mIsBadgeView) { noteBlock.setIsBadge() } - if (note.isViewMilestoneType) { - noteBlock.setIsViewMilestone() - } if (isPingback) { noteBlock.setIsPingback() } - noteList.add(noteBlock) + if (isRepliedFooter(noteObject).not()) { + // we don't handle replied footer at the moment + // it'd be better if we can display the replied comment + noteList.add(noteBlock) + } } catch (e: JSONException) { AppLog.e(NOTIFS, "Invalid note data, could not parse.") } @@ -545,7 +424,7 @@ class NotificationsDetailListFragment : ListFragment(), NotificationFragment { rootLayout!!.gravity = Gravity.CENTER_VERTICAL } if (noteBlockAdapter == null) { - noteBlockAdapter = NoteBlockAdapter(activity, noteList) + noteBlockAdapter = NoteBlockAdapter(requireContext(), noteList) listAdapter = noteBlockAdapter } else { noteBlockAdapter!!.setNoteList(noteList) @@ -562,16 +441,32 @@ class NotificationsDetailListFragment : ListFragment(), NotificationFragment { return false } + return requireNotNull(notification).let { note -> + if (isRepliedFooter(blockObject)) { + true + } else if (note.isFollowType || note.isLikeType) { + // User list notifications have a footer if they have 10 or more users in the body + // The last block will not have a type, so we can use that to determine if it is the footer + blockObject.type == null + } else { + false + } + } + } + + /** + * Check if the block is a footer for a comment notification that has been replied to + */ + private fun isRepliedFooter(blockObject: FormattableContent?): Boolean { + if (notification == null || blockObject == null) { + return false + } return requireNotNull(notification).let { note -> if (note.isCommentType) { val commentReplyId = blockObject.getRangeIdOrZero(1) // Check if this is a comment notification that has been replied to // The block will not have a type, and its id will match the comment reply id in the Note. (blockObject.type == null && note.commentReplyId == commentReplyId) - } else if (note.isFollowType || note.isLikeType) { - // User list notifications have a footer if they have 10 or more users in the body - // The last block will not have a type, so we can use that to determine if it is the footer - blockObject.type == null } else { false } @@ -638,6 +533,10 @@ class NotificationsDetailListFragment : ListFragment(), NotificationFragment { } } + fun setOnEditCommentListener(listener: CommentDetailFragment.OnActionClickListener) { + onActionClickListener = listener + } + companion object { private const val KEY_NOTE_ID = "noteId" private const val KEY_LIST_POSITION = "listPosition" @@ -645,7 +544,8 @@ class NotificationsDetailListFragment : ListFragment(), NotificationFragment { @JvmStatic fun newInstance(noteId: String?): NotificationsDetailListFragment { val fragment = NotificationsDetailListFragment() - fragment.setNote(noteId) + val bundle = Bundle().apply { putString(KEY_NOTE_ID, noteId) } + fragment.arguments = bundle return fragment } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragment.kt index a7d8534034cd..9a64dc54740a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragment.kt @@ -8,7 +8,6 @@ import android.app.Activity import android.content.Intent import android.os.Build import android.os.Bundle -import android.text.TextUtils import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater @@ -24,17 +23,15 @@ import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.MarginPageTransformer import com.google.android.material.appbar.AppBarLayout.LayoutParams -import com.google.android.material.tabs.TabLayout.OnTabSelectedListener -import com.google.android.material.tabs.TabLayout.Tab -import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint import org.greenrobot.eventbus.EventBus import org.wordpress.android.R import org.wordpress.android.analytics.AnalyticsTracker import org.wordpress.android.analytics.AnalyticsTracker.NOTIFICATIONS_SELECTED_FILTER +import org.wordpress.android.analytics.AnalyticsTracker.Stat.NOTIFICATIONS_FILTER_SELECTED import org.wordpress.android.analytics.AnalyticsTracker.Stat.NOTIFICATIONS_MARK_ALL_READ_TAPPED import org.wordpress.android.analytics.AnalyticsTracker.Stat.NOTIFICATION_MENU_TAPPED -import org.wordpress.android.analytics.AnalyticsTracker.Stat.NOTIFICATION_TAPPED_SEGMENTED_CONTROL +import org.wordpress.android.databinding.NotificationFilterPopupBinding import org.wordpress.android.databinding.NotificationsListFragmentBinding import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.models.JetpackPoweredScreen @@ -102,27 +99,11 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment) super.onViewCreated(view, savedInstanceState) setHasOptionsMenu(true) binding = NotificationsListFragmentBinding.bind(view).apply { - toolbarMain.setTitle(R.string.notifications_screen_title) + toolbarTitle.setOnClickListener { showFilterPopup(toolbarTitle) } (requireActivity() as AppCompatActivity).setSupportActionBar(toolbarMain) - tabLayout.addOnTabSelectedListener(object : OnTabSelectedListener { - override fun onTabSelected(tab: Tab) { - val tabPosition = TabPosition.values().getOrNull(tab.position) ?: All - AnalyticsTracker.track( - NOTIFICATION_TAPPED_SEGMENTED_CONTROL, hashMapOf( - NOTIFICATIONS_SELECTED_FILTER to tabPosition.filter.toString() - ) - ) - lastTabPosition = tab.position - } - - override fun onTabUnselected(tab: Tab) = Unit - override fun onTabReselected(tab: Tab) = Unit - }) viewPager.adapter = NotificationsFragmentAdapter(this@NotificationsListFragment) - TabLayoutMediator(tabLayout, viewPager) { tab, position -> - tab.text = TabPosition.values().getOrNull(position)?.let { getString(it.titleRes) } ?: "" - }.attach() + viewPager.isUserInputEnabled = false viewPager.setPageTransformer( MarginPageTransformer(resources.getDimensionPixelSize(R.dimen.margin_extra_large)) ) @@ -153,6 +134,37 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment) } } + private fun showFilterPopup(anchorView: View) { + val popupWindow = PopupWindow(requireContext(), null, R.style.WordPress) + popupWindow.isOutsideTouchable = true + popupWindow.elevation = resources.getDimension(R.dimen.popup_over_toolbar_elevation) + popupWindow.contentView = NotificationFilterPopupBinding.inflate(LayoutInflater.from(requireContext())) + .apply { + textFilterAll.setOnClickListener(getFilterClickListener(TabPosition.All, popupWindow)) + textFilterUnread.setOnClickListener(getFilterClickListener(TabPosition.Unread, popupWindow)) + textFilterComments.setOnClickListener(getFilterClickListener(TabPosition.Comment, popupWindow)) + textFilterSubscribers.setOnClickListener(getFilterClickListener(TabPosition.Subscribers, popupWindow)) + textFilterLikes.setOnClickListener(getFilterClickListener(TabPosition.Like, popupWindow)) + }.root + popupWindow.showAsDropDown(anchorView) + } + + private fun getFilterClickListener(filter: TabPosition, popupWindow: PopupWindow) = View.OnClickListener { + AnalyticsTracker.track( + NOTIFICATIONS_FILTER_SELECTED, hashMapOf( + NOTIFICATIONS_SELECTED_FILTER to filter.toString() + ) + ) + lastTabPosition = filter.ordinal + binding?.viewPager?.currentItem = filter.ordinal + binding?.toolbarTitle?.text = if (filter == All) { + getString(R.string.notifications_screen_spinner_title) + } else { + getString(filter.titleRes) + } + popupWindow.dismiss() + } + override fun onDestroyView() { super.onDestroyView() binding = null @@ -165,11 +177,9 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment) if (!accountStore.hasAccessToken()) { showConnectJetpackView() connectJetpack.visibility = View.VISIBLE - tabLayout.visibility = View.GONE viewPager.visibility = View.GONE } else { connectJetpack.visibility = View.GONE - tabLayout.visibility = View.VISIBLE viewPager.visibility = View.VISIBLE fetchRemoteNotes() } @@ -200,7 +210,14 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment) private fun NotificationsListFragmentBinding.setSelectedTab(position: Int) { lastTabPosition = position - tabLayout.getTabAt(lastTabPosition)?.select() + binding?.viewPager?.currentItem = position + TabPosition.entries.getOrNull(position)?.let { + toolbarTitle.text = if (it == All) { + getString(R.string.notifications_screen_spinner_title) + } else { + getString(it.titleRes) + } + } } private fun NotificationsListFragmentBinding.setNotificationPermissionWarning() { @@ -325,7 +342,6 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment) companion object { const val NOTE_ID_EXTRA = "noteId" const val NOTE_INSTANT_REPLY_EXTRA = "instantReply" - const val NOTE_PREFILLED_REPLY_EXTRA = "prefilledReplyText" const val NOTE_MODERATE_ID_EXTRA = "moderateNoteId" const val NOTE_MODERATE_STATUS_EXTRA = "moderateNoteStatus" const val NOTE_CURRENT_LIST_FILTER_EXTRA = "currentFilter" @@ -355,7 +371,6 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment) activity: Activity?, noteId: String?, shouldShowKeyboard: Boolean, - replyText: String?, filter: Filter?, isTappedFromPushNotification: Boolean ) { @@ -367,9 +382,6 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment) } val detailIntent = getOpenNoteIntent(activity, noteId) detailIntent.putExtra(NOTE_INSTANT_REPLY_EXTRA, shouldShowKeyboard) - if (!TextUtils.isEmpty(replyText)) { - detailIntent.putExtra(NOTE_PREFILLED_REPLY_EXTRA, replyText) - } detailIntent.putExtra(NOTE_CURRENT_LIST_FILTER_EXTRA, filter) detailIntent.putExtra(IS_TAPPED_ON_NOTIFICATION, isTappedFromPushNotification) openNoteForReplyWithParams(detailIntent, activity) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragmentPage.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragmentPage.kt index bbefe73e593a..452d0c1dc9a0 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragmentPage.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragmentPage.kt @@ -4,7 +4,6 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.os.Bundle -import android.text.TextUtils import android.util.AttributeSet import android.view.View import android.view.animation.Animation @@ -551,7 +550,6 @@ class NotificationsListFragmentPage : ViewPagerFragment(R.layout.notifications_l activity: Activity?, noteId: String?, shouldShowKeyboard: Boolean = false, - replyText: String? = null, filter: Filter? = null, isTappedFromPushNotification: Boolean = false, ) { @@ -560,9 +558,6 @@ class NotificationsListFragmentPage : ViewPagerFragment(R.layout.notifications_l } val detailIntent = getOpenNoteIntent(activity, noteId) detailIntent.putExtra(NotificationsListFragment.NOTE_INSTANT_REPLY_EXTRA, shouldShowKeyboard) - if (!TextUtils.isEmpty(replyText)) { - detailIntent.putExtra(NotificationsListFragment.NOTE_PREFILLED_REPLY_EXTRA, replyText) - } detailIntent.putExtra(NotificationsListFragment.NOTE_CURRENT_LIST_FILTER_EXTRA, filter) detailIntent.putExtra( NotificationsUpdateServiceStarter.IS_TAPPED_ON_NOTIFICATION, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NoteBlockAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NoteBlockAdapter.java deleted file mode 100644 index 13a9d4e3aa33..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NoteBlockAdapter.java +++ /dev/null @@ -1,60 +0,0 @@ -package org.wordpress.android.ui.notifications.adapters; - -import android.content.Context; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; - -import org.wordpress.android.R; -import org.wordpress.android.ui.notifications.blocks.NoteBlock; - -import java.util.List; - -public class NoteBlockAdapter extends ArrayAdapter { - private final LayoutInflater mLayoutInflater; - - private List mNoteBlockList; - - public NoteBlockAdapter(Context context, List noteBlocks) { - super(context, 0, noteBlocks); - - mNoteBlockList = noteBlocks; - mLayoutInflater = LayoutInflater.from(context); - } - - @Override - public boolean hasStableIds() { - return true; - } - - @Override - public int getCount() { - return mNoteBlockList == null ? 0 : mNoteBlockList.size(); - } - - @Override - public View getView(int position, View convertView, ViewGroup parent) { - NoteBlock noteBlock = mNoteBlockList.get(position); - - // Check the tag for this recycled view, if it matches we can reuse it - if (convertView == null || noteBlock.getBlockType() != convertView.getTag(R.id.note_block_tag_id)) { - convertView = mLayoutInflater.inflate(noteBlock.getLayoutResourceId(), parent, false); - convertView.setTag(noteBlock.getViewHolder(convertView)); - } - - // Update the block type for this view - convertView.setTag(R.id.note_block_tag_id, noteBlock.getBlockType()); - - return noteBlock.configureView(convertView); - } - - public void setNoteList(List noteList) { - if (noteList == null) { - return; - } - - mNoteBlockList = noteList; - notifyDataSetChanged(); - } -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NoteBlockAdapter.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NoteBlockAdapter.kt new file mode 100644 index 000000000000..0e4e27586d67 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NoteBlockAdapter.kt @@ -0,0 +1,40 @@ +package org.wordpress.android.ui.notifications.adapters + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import org.wordpress.android.R +import org.wordpress.android.ui.notifications.blocks.NoteBlock + +class NoteBlockAdapter(context: Context, private var noteBlocks: List) : + ArrayAdapter(context, 0, noteBlocks) { + override fun hasStableIds(): Boolean = true + + override fun getCount(): Int = noteBlocks.size + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val noteBlock = noteBlocks[position] + + // Check the tag for this recycled view, if it matches we can reuse it + return if (convertView == null || noteBlock.blockType != convertView.getTag(R.id.note_block_tag_id)) { + val view = LayoutInflater.from(parent.context).inflate(noteBlock.layoutResourceId, parent, false) + view.tag = noteBlock.getViewHolder(view) + view.setTag(R.id.note_block_tag_id, noteBlock.blockType) + noteBlock.configureView(view) + } else { + // Update the block type for this view + convertView.setTag(R.id.note_block_tag_id, noteBlock.blockType) + noteBlock.configureView(convertView) + } + } + + fun setNoteList(noteList: List?) { + if (noteList == null) { + return + } + noteBlocks = noteList + notifyDataSetChanged() + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/BlockType.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/BlockType.java deleted file mode 100644 index 5866a6aef542..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/BlockType.java +++ /dev/null @@ -1,37 +0,0 @@ -package org.wordpress.android.ui.notifications.blocks; - -import android.text.TextUtils; - -/** - * BlockTypes that we know about - * Unknown blocks will still be displayed using the rules for BASIC blocks - */ -public enum BlockType { - UNKNOWN, - BASIC, - USER, - USER_HEADER, - USER_COMMENT, - FOOTER; - - public static BlockType fromString(String blockType) { - if (TextUtils.isEmpty(blockType)) { - return UNKNOWN; - } - - switch (blockType) { - case "basic": - return BASIC; - case "user": - return USER; - case "user_header": - return USER_HEADER; - case "user_comment": - return USER_COMMENT; - case "footer": - return FOOTER; - default: - return UNKNOWN; - } - } -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/BlockType.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/BlockType.kt new file mode 100644 index 000000000000..fdb5fb2b6729 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/BlockType.kt @@ -0,0 +1,31 @@ +package org.wordpress.android.ui.notifications.blocks + +import android.text.TextUtils + +/** + * BlockTypes that we know about + * Unknown blocks will still be displayed using the rules for BASIC blocks + */ +enum class BlockType { + UNKNOWN, + BASIC, + USER, + USER_HEADER, + USER_COMMENT, + FOOTER; + + companion object { + fun fromString(blockType: String?): BlockType { + return if (TextUtils.isEmpty(blockType)) { + UNKNOWN + } else when (blockType) { + "basic" -> BASIC + "user" -> USER + "user_header" -> USER_HEADER + "user_comment" -> USER_COMMENT + "footer" -> FOOTER + else -> UNKNOWN + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/CommentUserNoteBlock.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/CommentUserNoteBlock.java deleted file mode 100644 index d655626e83f3..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/CommentUserNoteBlock.java +++ /dev/null @@ -1,292 +0,0 @@ -package org.wordpress.android.ui.notifications.blocks; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.text.Spannable; -import android.text.SpannableStringBuilder; -import android.text.TextUtils; -import android.view.View; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.core.text.HtmlCompat; -import androidx.core.view.ViewCompat; - -import org.wordpress.android.R; -import org.wordpress.android.fluxc.model.CommentStatus; -import org.wordpress.android.fluxc.tools.FormattableContent; -import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper; -import org.wordpress.android.util.extensions.ContextExtensionsKt; -import org.wordpress.android.util.DateTimeUtils; -import org.wordpress.android.util.WPAvatarUtils; -import org.wordpress.android.util.image.ImageManager; -import org.wordpress.android.util.image.ImageType; - -// A user block with slightly different formatting for display in a comment detail -public class CommentUserNoteBlock extends UserNoteBlock { - private static final String EMPTY_LINE = "\n\t"; - private static final String DOUBLE_EMPTY_LINE = "\n\t\n\t"; - private CommentStatus mCommentStatus = CommentStatus.APPROVED; - private int mNormalBackgroundColor; - private int mIndentedLeftPadding; - private final Context mContext; - private boolean mStatusChanged; - - private FormattableContent mCommentData; - private final long mTimestamp; - - private CommentUserNoteBlockHolder mNoteBlockHolder; - - public interface OnCommentStatusChangeListener { - void onCommentStatusChanged(CommentStatus newStatus); - } - - public CommentUserNoteBlock(Context context, FormattableContent noteObject, - FormattableContent commentTextBlock, - long timestamp, OnNoteBlockTextClickListener onNoteBlockTextClickListener, - OnGravatarClickedListener onGravatarClickedListener, - ImageManager imageManager, NotificationsUtilsWrapper notificationsUtilsWrapper) { - super(context, noteObject, onNoteBlockTextClickListener, onGravatarClickedListener, imageManager, - notificationsUtilsWrapper); - mContext = context; - mCommentData = commentTextBlock; - mTimestamp = timestamp; - - if (context != null) { - setAvatarSize(context.getResources().getDimensionPixelSize(R.dimen.avatar_sz_small)); - } - } - - @Override - public BlockType getBlockType() { - return BlockType.USER_COMMENT; - } - - @Override - public int getLayoutResourceId() { - return R.layout.note_block_comment_user; - } - - @SuppressLint("ClickableViewAccessibility") // fixed by setting a click listener to avatarImageView - @Override - public View configureView(View view) { - mNoteBlockHolder = (CommentUserNoteBlockHolder) view.getTag(); - - setUserName(); - setUserCommentAgo(); - setUserCommentSite(); - setUserAvatar(); - setUserComment(); - setCommentStatus(view); - - return view; - } - - private void setUserName() { - mNoteBlockHolder.mNameTextView.setText( - HtmlCompat.fromHtml( - "" + getNoteText().toString() + "", - HtmlCompat.FROM_HTML_MODE_LEGACY - ) - ); - } - - private void setUserCommentAgo() { - mNoteBlockHolder.mAgoTextView.setText(DateTimeUtils.timeSpanFromTimestamp(getTimestamp(), - mNoteBlockHolder.mAgoTextView.getContext())); - } - - private void setUserCommentSite() { - if (!TextUtils.isEmpty(getMetaHomeTitle()) || !TextUtils.isEmpty(getMetaSiteUrl())) { - mNoteBlockHolder.mBulletTextView.setVisibility(View.VISIBLE); - mNoteBlockHolder.mSiteTextView.setVisibility(View.VISIBLE); - if (!TextUtils.isEmpty(getMetaHomeTitle())) { - mNoteBlockHolder.mSiteTextView.setText(getMetaHomeTitle()); - } else { - mNoteBlockHolder.mSiteTextView.setText(getMetaSiteUrl().replace("http://", "").replace("https://", "")); - } - } else { - mNoteBlockHolder.mBulletTextView.setVisibility(View.GONE); - mNoteBlockHolder.mSiteTextView.setVisibility(View.GONE); - } - mNoteBlockHolder.mSiteTextView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); - } - - private void setUserAvatar() { - String imageUrl = ""; - if (hasImageMediaItem()) { - imageUrl = WPAvatarUtils.rewriteAvatarUrl(getNoteMediaItem().getUrl(), getAvatarSize()); - mNoteBlockHolder.mAvatarImageView.setContentDescription( - mContext.getString(R.string.profile_picture, getNoteText().toString()) - ); - if (!TextUtils.isEmpty(getUserUrl())) { - mNoteBlockHolder.mAvatarImageView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - showBlogPreview(); - } - }); - //noinspection AndroidLintClickableViewAccessibility - mNoteBlockHolder.mAvatarImageView.setOnTouchListener(mOnGravatarTouchListener); - } else { - mNoteBlockHolder.mAvatarImageView.setOnClickListener(null); - //noinspection AndroidLintClickableViewAccessibility - mNoteBlockHolder.mAvatarImageView.setOnTouchListener(null); - mNoteBlockHolder.mAvatarImageView.setContentDescription(null); - } - } else { - mNoteBlockHolder.mAvatarImageView.setOnClickListener(null); - //noinspection AndroidLintClickableViewAccessibility - mNoteBlockHolder.mAvatarImageView.setOnTouchListener(null); - mNoteBlockHolder.mAvatarImageView.setContentDescription(null); - } - mImageManager.loadIntoCircle(mNoteBlockHolder.mAvatarImageView, ImageType.AVATAR_WITH_BACKGROUND, imageUrl); - } - - private void setUserComment() { - Spannable spannable = getCommentTextOfNotification(mNoteBlockHolder); - NoteBlockClickableSpan[] spans = spannable.getSpans(0, spannable.length(), NoteBlockClickableSpan.class); - for (NoteBlockClickableSpan span : spans) { - span.enableColors(mContext); - } - - mNoteBlockHolder.mCommentTextView.setText(spannable); - } - - private void setCommentStatus(@NonNull final View view) { - // Change display based on comment status and type: - // 1. Comment replies are indented and have a 'pipe' background - // 2. Unapproved comments have different background and text color - int paddingStart = ViewCompat.getPaddingStart(view); - int paddingTop = view.getPaddingTop(); - int paddingEnd = ViewCompat.getPaddingEnd(view); - int paddingBottom = view.getPaddingBottom(); - if (mCommentStatus == CommentStatus.UNAPPROVED) { - if (hasCommentNestingLevel()) { - paddingStart = mIndentedLeftPadding; - view.setBackgroundResource(R.drawable.bg_rectangle_warning_surface_with_padding); - } else { - view.setBackgroundResource(R.drawable.bg_rectangle_warning_surface); - } - - mNoteBlockHolder.mDividerView.setVisibility(View.INVISIBLE); - } else { - if (hasCommentNestingLevel()) { - paddingStart = mIndentedLeftPadding; - view.setBackgroundResource(R.drawable.comment_reply_background); - mNoteBlockHolder.mDividerView.setVisibility(View.INVISIBLE); - } else { - view.setBackgroundColor(mNormalBackgroundColor); - mNoteBlockHolder.mDividerView.setVisibility(View.VISIBLE); - } - } - ViewCompat.setPaddingRelative(view, paddingStart, paddingTop, paddingEnd, paddingBottom); - // If status was changed, fade in the view - if (mStatusChanged) { - mStatusChanged = false; - view.setAlpha(0.4f); - view.animate().alpha(1.0f).start(); - } - } - - private Spannable getCommentTextOfNotification(CommentUserNoteBlockHolder noteBlockHolder) { - SpannableStringBuilder builder = mNotificationsUtilsWrapper - .getSpannableContentForRanges(mCommentData, - noteBlockHolder.mCommentTextView, getOnNoteBlockTextClickListener(), false); - return removeNewLineInList(builder); - } - - private Spannable removeNewLineInList(SpannableStringBuilder builder) { - String content = builder.toString(); - while (content.contains(DOUBLE_EMPTY_LINE)) { - int doubleSpaceIndex = content.indexOf(DOUBLE_EMPTY_LINE); - builder.replace(doubleSpaceIndex, doubleSpaceIndex + DOUBLE_EMPTY_LINE.length(), EMPTY_LINE); - content = builder.toString(); - } - return builder; - } - - private long getTimestamp() { - return mTimestamp; - } - - private boolean hasCommentNestingLevel() { - return mCommentData.getNestLevel() != null && mCommentData.getNestLevel() > 0; - } - - @Override - public Object getViewHolder(View view) { - return new CommentUserNoteBlockHolder(view); - } - - private class CommentUserNoteBlockHolder { - private final ImageView mAvatarImageView; - private final TextView mNameTextView; - private final TextView mAgoTextView; - private final TextView mBulletTextView; - private final TextView mSiteTextView; - private final TextView mCommentTextView; - private final View mDividerView; - - CommentUserNoteBlockHolder(View view) { - mNameTextView = view.findViewById(R.id.user_name); - mAgoTextView = view.findViewById(R.id.user_comment_ago); - mAgoTextView.setVisibility(View.VISIBLE); - mBulletTextView = view.findViewById(R.id.user_comment_bullet); - mSiteTextView = view.findViewById(R.id.user_comment_site); - mCommentTextView = view.findViewById(R.id.user_comment); - mCommentTextView.setMovementMethod(new NoteBlockLinkMovementMethod()); - mAvatarImageView = view.findViewById(R.id.user_avatar); - mDividerView = view.findViewById(R.id.divider_view); - - mSiteTextView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - if (getOnNoteBlockTextClickListener() != null) { - getOnNoteBlockTextClickListener().showSitePreview(getMetaSiteId(), getMetaSiteUrl()); - } - } - }); - - // show all comments on this post when user clicks the comment text - mCommentTextView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - if (getOnNoteBlockTextClickListener() != null) { - getOnNoteBlockTextClickListener().showReaderPostComments(); - } - } - }); - } - } - - public void configureResources(Context context) { - if (context == null) { - return; - } - - mNormalBackgroundColor = ContextExtensionsKt.getColorFromAttribute( - context, - com.google.android.material.R.attr.colorSurface - ); - // Double margin_extra_large for increased indent in comment replies - mIndentedLeftPadding = context.getResources().getDimensionPixelSize(R.dimen.margin_extra_large) * 2; - } - - private final OnCommentStatusChangeListener mOnCommentChangedListener = new OnCommentStatusChangeListener() { - @Override - public void onCommentStatusChanged(CommentStatus newStatus) { - mCommentStatus = newStatus; - mStatusChanged = true; - } - }; - - public void setCommentStatus(CommentStatus status) { - mCommentStatus = status; - } - - public OnCommentStatusChangeListener getOnCommentChangeListener() { - return mOnCommentChangedListener; - } -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/CommentUserNoteBlock.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/CommentUserNoteBlock.kt new file mode 100644 index 000000000000..94f423ad21d5 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/CommentUserNoteBlock.kt @@ -0,0 +1,176 @@ +package org.wordpress.android.ui.notifications.blocks + +import android.annotation.SuppressLint +import android.content.Context +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.TextUtils +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.CommentStatus +import org.wordpress.android.fluxc.tools.FormattableContent +import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper +import org.wordpress.android.util.DateTimeUtils +import org.wordpress.android.util.WPAvatarUtils +import org.wordpress.android.util.extensions.getColorFromAttribute +import org.wordpress.android.util.image.ImageManager +import org.wordpress.android.util.image.ImageType + +// A user block with slightly different formatting for display in a comment detail +@Suppress("LongParameterList") +class CommentUserNoteBlock( + private val context: Context?, noteObject: FormattableContent, + private val commentData: FormattableContent?, + private val timestamp: Long, onNoteBlockTextClickListener: OnNoteBlockTextClickListener?, + onGravatarClickedListener: OnGravatarClickedListener?, + imageManager: ImageManager, + notificationsUtilsWrapper: NotificationsUtilsWrapper +) : UserNoteBlock( + context, noteObject, onNoteBlockTextClickListener, onGravatarClickedListener, imageManager, + notificationsUtilsWrapper +) { + private var commentStatus = CommentStatus.APPROVED + private var normalBackgroundColor = 0 + private var indentedLeftPadding = 0 + private var statusChanged = false + private var holder: CommentUserNoteBlockHolder? = null + + override val blockType: BlockType + get() = BlockType.USER_COMMENT + override val layoutResourceId: Int + get() = R.layout.note_block_comment_user + + init { + avatarSize = context?.resources?.getDimensionPixelSize(R.dimen.avatar_sz_small) ?: 0 + } + + interface OnCommentStatusChangeListener { + fun onCommentStatusChanged(newStatus: CommentStatus) + } + + @SuppressLint("ClickableViewAccessibility") // fixed by setting a click listener to avatarImageView + override fun configureView(view: View): View { + holder = view.tag as CommentUserNoteBlockHolder + setUserName() + setUserCommentAgo() + setUserAvatar() + setUserComment() + return view + } + + private fun setUserName() { + holder?.textName?.text = noteText.toString() + } + + private fun setUserCommentAgo() { + holder?.textDate?.text = DateTimeUtils.timeSpanFromTimestamp( + timestamp, + holder?.textDate?.context + ) + } + + @SuppressLint("ClickableViewAccessibility") + private fun setUserAvatar() { + var imageUrl = "" + if (hasImageMediaItem()) { + noteMediaItem?.url?.let { imageUrl = WPAvatarUtils.rewriteAvatarUrl(it, avatarSize) } + holder?.imageAvatar?.contentDescription = + context?.getString(R.string.profile_picture, noteText.toString()) + if (!TextUtils.isEmpty(userUrl)) { + holder?.imageAvatar?.setOnClickListener { showBlogPreview() } + holder?.imageAvatar?.setOnTouchListener(mOnGravatarTouchListener) + } else { + holder?.imageAvatar?.setOnClickListener(null) + holder?.imageAvatar?.setOnTouchListener(null) + holder?.imageAvatar?.contentDescription = null + } + } else { + holder?.imageAvatar?.setOnClickListener(null) + holder?.imageAvatar?.setOnTouchListener(null) + holder?.imageAvatar?.contentDescription = null + } + holder?.imageAvatar?.let { + mImageManager.loadIntoCircle(it, ImageType.AVATAR_WITH_BACKGROUND, imageUrl) + } + } + + private fun setUserComment() { + val spannable = getCommentTextOfNotification(holder) + val spans = spannable.getSpans(0, spannable.length, NoteBlockClickableSpan::class.java) + context?.let { + for (span in spans) { + span.enableColors(it) + } + } + holder?.textComment?.text = spannable + } + + private fun getCommentTextOfNotification(noteBlockHolder: CommentUserNoteBlockHolder?): Spannable { + val builder = mNotificationsUtilsWrapper.getSpannableContentForRanges( + commentData, + noteBlockHolder?.textComment, + onNoteBlockTextClickListener, + false + ) + return removeNewLineInList(builder) + } + + private fun removeNewLineInList(builder: SpannableStringBuilder): Spannable { + var content = builder.toString() + while (content.contains(DOUBLE_EMPTY_LINE)) { + val doubleSpaceIndex = content.indexOf(DOUBLE_EMPTY_LINE) + builder.replace( + doubleSpaceIndex, + doubleSpaceIndex + DOUBLE_EMPTY_LINE.length, + EMPTY_LINE + ) + content = builder.toString() + } + return builder + } + + override fun getViewHolder(view: View): Any = CommentUserNoteBlockHolder(view) + + private inner class CommentUserNoteBlockHolder constructor(view: View) { + val imageAvatar: ImageView = view.findViewById(R.id.user_avatar) + val textName: TextView = view.findViewById(R.id.user_name) + val textDate: TextView = view.findViewById(R.id.user_comment_ago).apply { + visibility = View.VISIBLE + } + val textComment: TextView = view.findViewById(R.id.user_comment).apply { + movementMethod = NoteBlockLinkMovementMethod() + setOnClickListener { + // show all comments on this post when user clicks the comment text + onNoteBlockTextClickListener?.showReaderPostComments() + } + } + val buttonMore = view.findViewById(R.id.image_more).apply { + setOnClickListener { onNoteBlockTextClickListener?.showActionPopup(this) } + } + } + + fun configureResources(context: Context?) { + normalBackgroundColor = context?.getColorFromAttribute(com.google.android.material.R.attr.colorSurface) ?: 0 + // Double margin_extra_large for increased indent in comment replies + indentedLeftPadding = (context?.resources?.getDimensionPixelSize(R.dimen.margin_extra_large) ?: 0) * 2 + } + + val onCommentChangeListener: OnCommentStatusChangeListener = + object : OnCommentStatusChangeListener { + override fun onCommentStatusChanged(newStatus: CommentStatus) { + commentStatus = newStatus + statusChanged = true + } + } + + fun setCommentStatus(status: CommentStatus) { + commentStatus = status + } + + companion object { + private const val EMPTY_LINE = "\n\t" + private const val DOUBLE_EMPTY_LINE = "\n\t\n\t" + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/FooterNoteBlock.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/FooterNoteBlock.java deleted file mode 100644 index 4b89be9296e9..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/FooterNoteBlock.java +++ /dev/null @@ -1,133 +0,0 @@ -package org.wordpress.android.ui.notifications.blocks; - -import android.text.Spannable; -import android.text.TextUtils; -import android.view.View; -import android.widget.TextView; - -import androidx.annotation.NonNull; - -import org.wordpress.android.R; -import org.wordpress.android.fluxc.tools.FormattableContent; -import org.wordpress.android.fluxc.tools.FormattableRange; -import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper; -import org.wordpress.android.util.FormattableContentUtilsKt; -import org.wordpress.android.util.RtlUtils; -import org.wordpress.android.util.image.ImageManager; - -public class FooterNoteBlock extends NoteBlock { - private NoteBlockClickableSpan mClickableSpan; - - public FooterNoteBlock(FormattableContent noteObject, - ImageManager imageManager, - NotificationsUtilsWrapper notificationsUtilsWrapper, - OnNoteBlockTextClickListener onNoteBlockTextClickListener) { - super(noteObject, imageManager, notificationsUtilsWrapper, onNoteBlockTextClickListener); - } - - public void setClickableSpan(FormattableRange rangeObject, String noteType) { - if (rangeObject == null) { - return; - } - - mClickableSpan = new NoteBlockClickableSpan( - rangeObject, - false, - true - ); - - mClickableSpan.setCustomType(noteType); - } - - @Override - public BlockType getBlockType() { - return BlockType.FOOTER; - } - - @Override - public int getLayoutResourceId() { - return R.layout.note_block_footer; - } - - @Override - public View configureView(final View view) { - final FooterNoteBlockHolder noteBlockHolder = (FooterNoteBlockHolder) view.getTag(); - - // Note text - if (!TextUtils.isEmpty(getNoteText())) { - Spannable spannable = getNoteText(); - NoteBlockClickableSpan[] spans = spannable.getSpans(0, spannable.length(), NoteBlockClickableSpan.class); - for (NoteBlockClickableSpan span : spans) { - span.enableColors(view.getContext()); - } - - noteBlockHolder.getTextView().setText(spannable); - noteBlockHolder.getTextView().setVisibility(View.VISIBLE); - } else { - noteBlockHolder.getTextView().setVisibility(View.GONE); - } - - String noticonGlyph = getNoticonGlyph(); - if (!TextUtils.isEmpty(noticonGlyph)) { - noteBlockHolder.getNoticonView().setVisibility(View.VISIBLE); - noteBlockHolder.getNoticonView().setText(noticonGlyph); - // mirror noticon in the rtl mode - if (RtlUtils.isRtl(noteBlockHolder.getNoticonView().getContext())) { - noteBlockHolder.getNoticonView().setScaleX(-1); - } - } else { - noteBlockHolder.getNoticonView().setVisibility(View.GONE); - } - - return view; - } - - @NonNull - private String getNoticonGlyph() { - return FormattableContentUtilsKt.getRangeValueOrEmpty(getNoteData(), 0); - } - - @Override - Spannable getNoteText() { - return mNotificationsUtilsWrapper.getSpannableContentForRanges(getNoteData(), null, - getOnNoteBlockTextClickListener(), true); - } - - public Object getViewHolder(View view) { - return new FooterNoteBlockHolder(view); - } - - class FooterNoteBlockHolder { - private final View mFooterView; - private final TextView mTextView; - private final TextView mNoticonView; - - FooterNoteBlockHolder(View view) { - mFooterView = view.findViewById(R.id.note_footer); - mFooterView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - onRangeClick(); - } - }); - mTextView = view.findViewById(R.id.note_footer_text); - mNoticonView = view.findViewById(R.id.note_footer_noticon); - } - - public TextView getTextView() { - return mTextView; - } - - public TextView getNoticonView() { - return mNoticonView; - } - } - - private void onRangeClick() { - if (mClickableSpan == null || getOnNoteBlockTextClickListener() == null) { - return; - } - - getOnNoteBlockTextClickListener().onNoteBlockTextClicked(mClickableSpan); - } -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/FooterNoteBlock.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/FooterNoteBlock.kt new file mode 100644 index 000000000000..d52f6c43d103 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/FooterNoteBlock.kt @@ -0,0 +1,86 @@ +package org.wordpress.android.ui.notifications.blocks + +import android.text.Spannable +import android.text.TextUtils +import android.view.View +import android.widget.TextView +import org.wordpress.android.R +import org.wordpress.android.fluxc.tools.FormattableContent +import org.wordpress.android.fluxc.tools.FormattableRange +import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper +import org.wordpress.android.util.RtlUtils +import org.wordpress.android.util.getRangeValueOrEmpty +import org.wordpress.android.util.image.ImageManager + +class FooterNoteBlock( + noteObject: FormattableContent, + imageManager: ImageManager, + notificationsUtilsWrapper: NotificationsUtilsWrapper, + onNoteBlockTextClickListener: OnNoteBlockTextClickListener? +) : NoteBlock(noteObject, imageManager, notificationsUtilsWrapper, onNoteBlockTextClickListener) { + private lateinit var mClickableSpan: NoteBlockClickableSpan + + private val noticonGlyph: String + get() = noteData.getRangeValueOrEmpty(0) + + override val noteText: Spannable + get() = mNotificationsUtilsWrapper.getSpannableContentForRanges( + noteData, null, + onNoteBlockTextClickListener, true + ) + override val blockType: BlockType + get() = BlockType.FOOTER + override val layoutResourceId: Int + get() = R.layout.note_block_footer + + fun setClickableSpan(rangeObject: FormattableRange?, noteType: String?) { + if (rangeObject == null) { + return + } + mClickableSpan = NoteBlockClickableSpan(rangeObject, mShouldLink = false, mIsFooter = true) + mClickableSpan.setCustomType(noteType) + } + + + override fun configureView(view: View): View { + val noteBlockHolder = view.tag as FooterNoteBlockHolder + + // Note text + if (!TextUtils.isEmpty(noteText)) { + val spannable = noteText + val spans = spannable.getSpans(0, spannable.length, NoteBlockClickableSpan::class.java) + for (span in spans) { + span.enableColors(view.context) + } + noteBlockHolder.textView.text = spannable + noteBlockHolder.textView.visibility = View.VISIBLE + } else { + noteBlockHolder.textView.visibility = View.GONE + } + val noticonGlyph = noticonGlyph + if (!TextUtils.isEmpty(noticonGlyph)) { + noteBlockHolder.noticonView.visibility = View.VISIBLE + noteBlockHolder.noticonView.text = noticonGlyph + // mirror noticon in the rtl mode + if (RtlUtils.isRtl(noteBlockHolder.noticonView.context)) { + noteBlockHolder.noticonView.scaleX = -1f + } + } else { + noteBlockHolder.noticonView.visibility = View.GONE + } + return view + } + + override fun getViewHolder(view: View): Any = FooterNoteBlockHolder(view) + + internal inner class FooterNoteBlockHolder(view: View) { + val textView: TextView = view.findViewById(R.id.note_footer_text) + val noticonView: TextView = view.findViewById(R.id.note_footer_noticon) + + init { + view.findViewById(R.id.note_footer).setOnClickListener { + onNoteBlockTextClickListener?.onNoteBlockTextClicked(mClickableSpan) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/GeneratedNoteBlock.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/GeneratedNoteBlock.kt index b38717d7a778..facbfea0e687 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/GeneratedNoteBlock.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/GeneratedNoteBlock.kt @@ -20,7 +20,14 @@ class GeneratedNoteBlock( val clickListener: OnNoteBlockTextClickListener, private val pingbackUrl: String ) : NoteBlock(FormattableContent(), imageManager, notificationsUtilsWrapper, clickListener) { - override fun getNoteText(): Spannable { + override val metaSiteUrl: String + get() = pingbackUrl + + override val blockType: BlockType + get() = BASIC + + override val noteText: Spannable + get() { val spannableStringBuilder = SpannableStringBuilder(text) // Process Ranges to add links and text formatting @@ -42,15 +49,7 @@ class GeneratedNoteBlock( return spannableStringBuilder } - override fun getMetaSiteUrl(): String { - return pingbackUrl - } - override fun hasImageMediaItem(): Boolean { return false } - - override fun getBlockType(): BlockType { - return BASIC - } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/HeaderNoteBlock.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/HeaderNoteBlock.java deleted file mode 100644 index 603b977d059c..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/HeaderNoteBlock.java +++ /dev/null @@ -1,199 +0,0 @@ -package org.wordpress.android.ui.notifications.blocks; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.text.Spannable; -import android.text.TextUtils; -import android.view.MotionEvent; -import android.view.View; -import android.view.animation.DecelerateInterpolator; -import android.widget.ImageView; -import android.widget.TextView; - -import org.wordpress.android.R; -import org.wordpress.android.fluxc.tools.FormattableContent; -import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper; -import org.wordpress.android.util.FormattableContentUtilsKt; -import org.wordpress.android.util.WPAvatarUtils; -import org.wordpress.android.util.image.ImageManager; -import org.wordpress.android.util.image.ImageType; - -import java.util.List; - -// Note header, displayed at top of detail view -public class HeaderNoteBlock extends NoteBlock { - private final List mHeadersList; - - private final UserNoteBlock.OnGravatarClickedListener mGravatarClickedListener; - private Boolean mIsComment; - private int mAvatarSize; - - private ImageType mImageType; - - public HeaderNoteBlock(Context context, List headerArray, ImageType imageType, - OnNoteBlockTextClickListener onNoteBlockTextClickListener, - UserNoteBlock.OnGravatarClickedListener onGravatarClickedListener, - ImageManager imageManager, NotificationsUtilsWrapper notificationsUtilsWrapper) { - super(new FormattableContent(), imageManager, notificationsUtilsWrapper, onNoteBlockTextClickListener); - mHeadersList = headerArray; - mImageType = imageType; - mGravatarClickedListener = onGravatarClickedListener; - - if (context != null) { - mAvatarSize = context.getResources().getDimensionPixelSize(R.dimen.avatar_sz_small); - } - } - - @Override - public BlockType getBlockType() { - return BlockType.USER_HEADER; - } - - public int getLayoutResourceId() { - return R.layout.note_block_header; - } - - @SuppressLint("ClickableViewAccessibility") // fixed by setting a click listener to avatarImageView - @Override - public View configureView(View view) { - final NoteHeaderBlockHolder noteBlockHolder = (NoteHeaderBlockHolder) view.getTag(); - - Spannable spannable = mNotificationsUtilsWrapper.getSpannableContentForRanges(mHeadersList.get(0)); - NoteBlockClickableSpan[] spans = spannable.getSpans(0, spannable.length(), NoteBlockClickableSpan.class); - for (NoteBlockClickableSpan span : spans) { - span.enableColors(view.getContext()); - } - noteBlockHolder.mNameTextView.setText(spannable); - if (mImageType == ImageType.AVATAR_WITH_BACKGROUND) { - mImageManager.loadIntoCircle(noteBlockHolder.mAvatarImageView, mImageType, getAvatarUrl()); - } else { - mImageManager.load(noteBlockHolder.mAvatarImageView, mImageType, getAvatarUrl()); - } - - final long siteId = FormattableContentUtilsKt.getRangeSiteIdOrZero(getHeader(0), 0); - final long userId = FormattableContentUtilsKt.getRangeIdOrZero(getHeader(0), 0); - - if (!TextUtils.isEmpty(getUserUrl()) && siteId > 0 && userId > 0) { - noteBlockHolder.mAvatarImageView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - String siteUrl = getUserUrl(); - mGravatarClickedListener.onGravatarClicked(siteId, userId, siteUrl); - } - }); - - - noteBlockHolder.mAvatarImageView.setContentDescription( - view.getContext().getString(R.string.profile_picture, spannable)); - //noinspection AndroidLintClickableViewAccessibility - noteBlockHolder.mAvatarImageView.setOnTouchListener(mOnGravatarTouchListener); - - if (siteId == userId) { - noteBlockHolder.mAvatarImageView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); - } else { - noteBlockHolder.mAvatarImageView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); - } - } else { - noteBlockHolder.mAvatarImageView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); - noteBlockHolder.mAvatarImageView.setContentDescription(null); - noteBlockHolder.mAvatarImageView.setOnClickListener(null); - //noinspection AndroidLintClickableViewAccessibility - noteBlockHolder.mAvatarImageView.setOnTouchListener(null); - } - - noteBlockHolder.mSnippetTextView.setText(getSnippet()); - - if (mIsComment) { - View footerView = view.findViewById(R.id.header_footer); - View footerCommentView = view.findViewById(R.id.header_footer_comment); - footerView.setVisibility(View.GONE); - footerCommentView.setVisibility(View.VISIBLE); - } - - return view; - } - - private final View.OnClickListener mOnClickListener = new View.OnClickListener() { - @Override - public void onClick(View v) { - if (getOnNoteBlockTextClickListener() != null) { - getOnNoteBlockTextClickListener().showDetailForNoteIds(); - } - } - }; - - private String getAvatarUrl() { - return WPAvatarUtils.rewriteAvatarUrl(FormattableContentUtilsKt.getMediaUrlOrEmpty( - getHeader(0), 0), mAvatarSize); - } - - private String getUserUrl() { - return FormattableContentUtilsKt.getRangeUrlOrEmpty(getHeader(0), 0); - } - - private String getSnippet() { - return FormattableContentUtilsKt.getTextOrEmpty(getHeader(1)); - } - - @Override - public Object getViewHolder(View view) { - return new NoteHeaderBlockHolder(view); - } - - public void setIsComment(Boolean isComment) { - mIsComment = isComment; - } - - private class NoteHeaderBlockHolder { - private final TextView mNameTextView; - private final TextView mSnippetTextView; - private final ImageView mAvatarImageView; - - NoteHeaderBlockHolder(View view) { - View rootView = view.findViewById(R.id.header_root_view); - rootView.setOnClickListener(mOnClickListener); - mNameTextView = view.findViewById(R.id.header_user); - mSnippetTextView = view.findViewById(R.id.header_snippet); - mAvatarImageView = view.findViewById(R.id.header_avatar); - } - } - - private final View.OnTouchListener mOnGravatarTouchListener = new View.OnTouchListener() { - @Override - public boolean onTouch(View v, MotionEvent event) { - int animationDuration = 150; - - if (event.getAction() == MotionEvent.ACTION_DOWN) { - v.animate() - .scaleX(0.9f) - .scaleY(0.9f) - .alpha(0.5f) - .setDuration(animationDuration) - .setInterpolator(new DecelerateInterpolator()); - } else if (event.getActionMasked() == MotionEvent.ACTION_UP - || event.getActionMasked() == MotionEvent.ACTION_CANCEL) { - v.animate() - .scaleX(1.0f) - .scaleY(1.0f) - .alpha(1.0f) - .setDuration(animationDuration) - .setInterpolator(new DecelerateInterpolator()); - - if (event.getActionMasked() == MotionEvent.ACTION_UP && mGravatarClickedListener != null) { - // Fire the listener, which will load the site preview for the user's site - // In the future we can use this to load a 'profile view' (currently in R&D) - v.performClick(); - } - } - - return true; - } - }; - - public FormattableContent getHeader(int headerIndex) { - if (mHeadersList != null && headerIndex < mHeadersList.size()) { - return mHeadersList.get(headerIndex); - } - return null; - } -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/HeaderNoteBlock.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/HeaderNoteBlock.kt new file mode 100644 index 000000000000..1ba70f90da17 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/HeaderNoteBlock.kt @@ -0,0 +1,149 @@ +package org.wordpress.android.ui.notifications.blocks + +import android.annotation.SuppressLint +import android.content.Context +import android.text.Spannable +import android.text.TextUtils +import android.view.MotionEvent +import android.view.View +import android.view.View.OnTouchListener +import android.view.animation.DecelerateInterpolator +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isVisible +import org.wordpress.android.R +import org.wordpress.android.fluxc.tools.FormattableContent +import org.wordpress.android.ui.notifications.blocks.UserNoteBlock.OnGravatarClickedListener +import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper +import org.wordpress.android.util.WPAvatarUtils +import org.wordpress.android.util.getMediaUrlOrEmpty +import org.wordpress.android.util.getRangeIdOrZero +import org.wordpress.android.util.getRangeSiteIdOrZero +import org.wordpress.android.util.getRangeUrlOrEmpty +import org.wordpress.android.util.getTextOrEmpty +import org.wordpress.android.util.image.ImageManager +import org.wordpress.android.util.image.ImageType + +// Note header, displayed at top of detail view +@Suppress("LongParameterList") +class HeaderNoteBlock( + context: Context?, + private val mHeadersList: List?, + private val mImageType: ImageType, + onNoteBlockTextClickListener: OnNoteBlockTextClickListener?, + private val mGravatarClickedListener: OnGravatarClickedListener?, + imageManager: ImageManager, + notificationsUtilsWrapper: NotificationsUtilsWrapper +) : NoteBlock( + FormattableContent(), + imageManager, + notificationsUtilsWrapper, + onNoteBlockTextClickListener +) { + private var replyToComment = false + private var mAvatarSize = 0 + override val blockType: BlockType + get() = BlockType.USER_HEADER + override val layoutResourceId: Int + get() = R.layout.note_block_header + private val avatarUrl: String + get() = WPAvatarUtils.rewriteAvatarUrl(getHeader(0).getMediaUrlOrEmpty(0), mAvatarSize) + private val userUrl: String + get() = getHeader(0).getRangeUrlOrEmpty(0) + private val snippet: String + get() = getHeader(1).getTextOrEmpty() + + private val mOnClickListener = View.OnClickListener { onNoteBlockTextClickListener?.showDetailForNoteIds() } + + init { + mAvatarSize = context?.resources?.getDimensionPixelSize(R.dimen.avatar_sz_small) ?: 0 + } + + @SuppressLint("ClickableViewAccessibility") // fixed by setting a click listener to avatarImageView + override fun configureView(view: View): View { + val noteBlockHolder = view.tag as NoteHeaderBlockHolder + val spannable: Spannable = mNotificationsUtilsWrapper.getSpannableContentForRanges(mHeadersList?.getOrNull(0)) + val spans = spannable.getSpans(0, spannable.length, NoteBlockClickableSpan::class.java) + for (span in spans) { + span.enableColors(view.context) + } + if (mImageType == ImageType.AVATAR_WITH_BACKGROUND) { + mImageManager.loadIntoCircle(noteBlockHolder.mAvatarImageView, mImageType, avatarUrl) + } else { + mImageManager.load(noteBlockHolder.mAvatarImageView, mImageType, avatarUrl) + } + val siteId = getHeader(0).getRangeSiteIdOrZero(0) + val userId = getHeader(0).getRangeIdOrZero(0) + if (!TextUtils.isEmpty(userUrl) && siteId > 0 && userId > 0) { + noteBlockHolder.mAvatarImageView.setOnClickListener { + mGravatarClickedListener?.onGravatarClicked(siteId, userId, userUrl) + } + noteBlockHolder.mAvatarImageView.contentDescription = + view.context.getString(R.string.profile_picture, spannable) + noteBlockHolder.mAvatarImageView.setOnTouchListener(mOnGravatarTouchListener) + if (siteId == userId) { + noteBlockHolder.mAvatarImageView.importantForAccessibility = + View.IMPORTANT_FOR_ACCESSIBILITY_NO + } else { + noteBlockHolder.mAvatarImageView.importantForAccessibility = + View.IMPORTANT_FOR_ACCESSIBILITY_YES + } + } else { + noteBlockHolder.mAvatarImageView.importantForAccessibility = + View.IMPORTANT_FOR_ACCESSIBILITY_NO + noteBlockHolder.mAvatarImageView.contentDescription = null + noteBlockHolder.mAvatarImageView.setOnClickListener(null) + noteBlockHolder.mAvatarImageView.setOnTouchListener(null) + } + noteBlockHolder.mAvatarImageView.isVisible = replyToComment + noteBlockHolder.mSnippetTextView.text = snippet + return view + } + + override fun getViewHolder(view: View): Any = NoteHeaderBlockHolder(view) + + fun getHeader(headerIndex: Int): FormattableContent? = mHeadersList?.getOrNull(headerIndex) + + /** + * Set whether this is a reply to a comment + */ + fun setReplyToComment(isReplyToComment: Boolean) { + replyToComment = isReplyToComment + } + + private inner class NoteHeaderBlockHolder internal constructor(view: View) { + val mSnippetTextView: TextView = view.findViewById(R.id.header_snippet) + val mAvatarImageView: ImageView = view.findViewById(R.id.header_avatar) + + init { + view.findViewById(R.id.header_root_view).setOnClickListener(mOnClickListener) + } + } + + @Suppress("MagicNumber") + private val mOnGravatarTouchListener = OnTouchListener { v, event -> + val animationDuration = 150 + when (event.action) { + MotionEvent.ACTION_DOWN -> { + v.animate().scaleX(0.9f).scaleY(0.9f) + .alpha(0.5f) + .setDuration(animationDuration.toLong()) + .setInterpolator(DecelerateInterpolator()) + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> { + v.animate().scaleX(1.0f).scaleY(1.0f) + .alpha(1.0f) + .setDuration(animationDuration.toLong()) + .setInterpolator(DecelerateInterpolator()) + if (event.actionMasked == MotionEvent.ACTION_UP && mGravatarClickedListener != null) { + // Fire the listener, which will load the site preview for the user's site + // In the future we can use this to load a 'profile view' (currently in R&D) + v.performClick() + } + } + } + true + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/MilestoneNoteBlock.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/MilestoneNoteBlock.kt new file mode 100644 index 000000000000..9fdf7b0cb66b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/MilestoneNoteBlock.kt @@ -0,0 +1,29 @@ +package org.wordpress.android.ui.notifications.blocks + +import android.text.Spannable +import android.text.SpannableString +import org.wordpress.android.R +import org.wordpress.android.fluxc.tools.FormattableContent +import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper +import org.wordpress.android.util.image.ImageManager + +class MilestoneNoteBlock( + noteData: FormattableContent, + imageManager: ImageManager, + notificationsUtilsWrapper: NotificationsUtilsWrapper, + onNoteBlockTextClickListener: OnNoteBlockTextClickListener?, +) : NoteBlock( + noteData, + imageManager, + notificationsUtilsWrapper, + onNoteBlockTextClickListener +) { + override var mIsBadge = true + override var mIsViewMilestone = true + override val layoutResourceId: Int + get() = R.layout.note_block_milestone + + // The first item of the list which contains the badge is skipped because it is used for the legacy screen's title. + override val noteText: Spannable + get() = if(containsBadgeMediaType()) SpannableString("") else super.noteText +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlock.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlock.java deleted file mode 100644 index ae7bb9d88764..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlock.java +++ /dev/null @@ -1,358 +0,0 @@ -package org.wordpress.android.ui.notifications.blocks; - -import android.graphics.drawable.Drawable; -import android.media.MediaPlayer; -import android.net.Uri; -import android.text.Spannable; -import android.text.TextUtils; -import android.text.style.TypefaceSpan; -import android.view.Gravity; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.MediaController; -import android.widget.VideoView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.wordpress.android.R; -import org.wordpress.android.fluxc.tools.FormattableContent; -import org.wordpress.android.fluxc.tools.FormattableMedia; -import org.wordpress.android.fluxc.tools.FormattableRange; -import org.wordpress.android.util.image.GlidePopTransitionOptions; -import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper; -import org.wordpress.android.util.AccessibilityUtils; -import org.wordpress.android.util.AppLog; -import org.wordpress.android.util.AppLog.T; -import org.wordpress.android.util.DisplayUtils; -import org.wordpress.android.util.FormattableContentUtilsKt; -import org.wordpress.android.util.StringUtils; -import org.wordpress.android.util.image.ImageManager; -import org.wordpress.android.util.image.ImageType; -import org.wordpress.android.widgets.WPTextView; - -/** - * A block of data displayed in a notification. - * This basic block can support a media item (image/video) and/or text. - */ -public class NoteBlock { - private final FormattableContent mNoteData; - private final OnNoteBlockTextClickListener mOnNoteBlockTextClickListener; - protected final ImageManager mImageManager; - protected final NotificationsUtilsWrapper mNotificationsUtilsWrapper; - private boolean mIsBadge; - private boolean mIsPingback; - private boolean mIsViewMilestone; - - public interface OnNoteBlockTextClickListener { - void onNoteBlockTextClicked(NoteBlockClickableSpan clickedSpan); - - void showDetailForNoteIds(); - - void showReaderPostComments(); - - void showSitePreview(long siteId, String siteUrl); - } - - public NoteBlock(FormattableContent noteObject, ImageManager imageManager, - NotificationsUtilsWrapper notificationsUtilsWrapper, - OnNoteBlockTextClickListener onNoteBlockTextClickListener) { - mNoteData = noteObject; - mOnNoteBlockTextClickListener = onNoteBlockTextClickListener; - mImageManager = imageManager; - mNotificationsUtilsWrapper = notificationsUtilsWrapper; - } - - OnNoteBlockTextClickListener getOnNoteBlockTextClickListener() { - return mOnNoteBlockTextClickListener; - } - - public BlockType getBlockType() { - return BlockType.BASIC; - } - - FormattableContent getNoteData() { - return mNoteData; - } - - Spannable getNoteText() { - return mNotificationsUtilsWrapper.getSpannableContentForRanges(mNoteData, null, - mOnNoteBlockTextClickListener, false); - } - - String getMetaHomeTitle() { - return FormattableContentUtilsKt.getMetaTitlesHomeOrEmpty(mNoteData); - } - - long getMetaSiteId() { - return FormattableContentUtilsKt.getMetaIdsSiteIdOrZero(mNoteData); - } - - public String getMetaSiteUrl() { - return FormattableContentUtilsKt.getMetaLinksHomeOrEmpty(mNoteData); - } - - private boolean isPingBack() { - return mIsPingback; - } - - public void setIsPingback() { - mIsPingback = true; - } - - @Nullable public FormattableMedia getNoteMediaItem() { - return FormattableContentUtilsKt.getMediaOrNull(mNoteData, 0); - } - - public void setIsBadge() { - mIsBadge = true; - } - - public void setIsViewMilestone() { - mIsViewMilestone = true; - } - - public int getLayoutResourceId() { - return R.layout.note_block_basic; - } - - private boolean hasMediaArray() { - return mNoteData.getMedia() != null && !mNoteData.getMedia().isEmpty(); - } - - public boolean hasImageMediaItem() { - return hasMediaArray() - && getNoteMediaItem() != null - && !TextUtils.isEmpty(getNoteMediaItem().getType()) - && (getNoteMediaItem().getType().startsWith("image") || getNoteMediaItem().getType().equals("badge")) - && !TextUtils.isEmpty(getNoteMediaItem().getUrl()); - } - - private boolean hasVideoMediaItem() { - return hasMediaArray() - && getNoteMediaItem() != null - && !TextUtils.isEmpty(getNoteMediaItem().getType()) - && getNoteMediaItem().getType().startsWith("video") - && !TextUtils.isEmpty(getNoteMediaItem().getUrl()); - } - - public boolean containsBadgeMediaType() { - if (mNoteData.getMedia() != null) { - for (FormattableMedia mediaObject : mNoteData.getMedia()) { - if ("badge".equals(mediaObject.getType())) { - return true; - } - } - } - return false; - } - - public View configureView(final View view) { - final BasicNoteBlockHolder noteBlockHolder = (BasicNoteBlockHolder) view.getTag(); - - // Note image - if (hasImageMediaItem()) { - noteBlockHolder.getImageView().setVisibility(View.VISIBLE); - // Request image, and animate it when loaded - mImageManager.animateWithResultListener(noteBlockHolder.getImageView(), ImageType.IMAGE, - StringUtils.notNullStr(getNoteMediaItem().getUrl()), - GlidePopTransitionOptions.INSTANCE.pop(), - new ImageManager.RequestListener() { - @Override - public void onLoadFailed(@Nullable Exception e, @Nullable Object model) { - if (e != null) { - AppLog.e(T.NOTIFS, e); - } - noteBlockHolder.hideImageView(); - } - - @Override - public void onResourceReady(@NonNull Drawable resource, @Nullable Object model) { - } - }); - - if (mIsBadge) { - noteBlockHolder.getImageView().setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); - } - } else { - mImageManager.cancelRequestAndClearImageView(noteBlockHolder.getImageView()); - noteBlockHolder.hideImageView(); - } - - // Note video - if (hasVideoMediaItem()) { - noteBlockHolder.getVideoView().setVideoURI(Uri.parse(StringUtils.notNullStr(getNoteMediaItem().getUrl()))); - noteBlockHolder.getVideoView().setVisibility(View.VISIBLE); - } else { - noteBlockHolder.hideVideoView(); - } - - // Note text - Spannable noteText = getNoteText(); - if (!TextUtils.isEmpty(noteText)) { - if (isPingBack()) { - noteBlockHolder.getTextView().setVisibility(View.GONE); - noteBlockHolder.getMaterialButton().setVisibility(View.GONE); - noteBlockHolder.getDivider().setVisibility(View.VISIBLE); - noteBlockHolder.getButton().setVisibility(View.VISIBLE); - noteBlockHolder.getButton().setText(noteText.toString()); - noteBlockHolder.getButton().setOnClickListener(v -> { - if (getOnNoteBlockTextClickListener() != null) { - getOnNoteBlockTextClickListener().showSitePreview(0, getMetaSiteUrl()); - } - }); - } else { - int textViewVisibility = View.VISIBLE; - if (mIsBadge) { - LinearLayout.LayoutParams params = - new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.MATCH_PARENT); - params.gravity = Gravity.CENTER_HORIZONTAL; - noteBlockHolder.getTextView().setLayoutParams(params); - noteBlockHolder.getTextView().setGravity(Gravity.CENTER_HORIZONTAL); - int padding; - if (mIsViewMilestone) { - padding = 40; - } else { - padding = 8; - } - noteBlockHolder.getTextView().setPadding(0, DisplayUtils.dpToPx(view.getContext(), padding), 0, 0); - - if (AccessibilityUtils.isAccessibilityEnabled(noteBlockHolder.getTextView().getContext())) { - noteBlockHolder.getTextView().setClickable(false); - noteBlockHolder.getTextView().setLongClickable(false); - } - if (mIsViewMilestone) { - if (FormattableContentUtilsKt.isMobileButton(mNoteData)) { - textViewVisibility = View.GONE; - noteBlockHolder.getButton().setVisibility(View.GONE); - noteBlockHolder.getMaterialButton().setVisibility(View.VISIBLE); - noteBlockHolder.getMaterialButton().setText(noteText.toString()); - noteBlockHolder.getMaterialButton().setOnClickListener(v -> { - FormattableRange buttonRange = - FormattableContentUtilsKt.getMobileButtonRange(mNoteData); - if (getOnNoteBlockTextClickListener() != null && buttonRange != null) { - NoteBlockClickableSpan clickableSpan = - new NoteBlockClickableSpan(buttonRange, true, false); - getOnNoteBlockTextClickListener().onNoteBlockTextClicked(clickableSpan); - } - }); - } else { - noteBlockHolder.getTextView().setTextSize(28); - TypefaceSpan typefaceSpan = new TypefaceSpan("sans-serif"); - noteText.setSpan(typefaceSpan, 0, noteText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - } - } - } else { - noteBlockHolder.getTextView().setGravity(Gravity.NO_GRAVITY); - noteBlockHolder.getTextView().setPadding(0, 0, 0, 0); - } - - NoteBlockClickableSpan[] spans = noteText.getSpans(0, noteText.length(), NoteBlockClickableSpan.class); - for (NoteBlockClickableSpan span : spans) { - span.enableColors(view.getContext()); - } - noteBlockHolder.getTextView().setText(noteText); - noteBlockHolder.getTextView().setVisibility(textViewVisibility); - } - } else { - noteBlockHolder.getButton().setVisibility(View.GONE); - noteBlockHolder.getDivider().setVisibility(View.GONE); - noteBlockHolder.getMaterialButton().setVisibility(View.GONE); - noteBlockHolder.getTextView().setVisibility(View.GONE); - } - - return view; - } - - public Object getViewHolder(View view) { - return new BasicNoteBlockHolder(view); - } - - static class BasicNoteBlockHolder { - private final LinearLayout mRootLayout; - private final WPTextView mTextView; - private final Button mButton; - private final Button mMaterialButton; - private final View mDivider; - - private ImageView mImageView; - private VideoView mVideoView; - - BasicNoteBlockHolder(View view) { - mRootLayout = (LinearLayout) view; - mTextView = view.findViewById(R.id.note_text); - mTextView.setMovementMethod(new NoteBlockLinkMovementMethod()); - mButton = view.findViewById(R.id.note_button); - mMaterialButton = view.findViewById(R.id.note_material_button); - mDivider = view.findViewById(R.id.divider_view); - } - - public WPTextView getTextView() { - return mTextView; - } - - public Button getButton() { - return mButton; - } - - public Button getMaterialButton() { - return mMaterialButton; - } - - public View getDivider() { - return mDivider; - } - - public ImageView getImageView() { - if (mImageView == null) { - mImageView = mRootLayout.findViewById(R.id.image); - } - - return mImageView; - } - - public VideoView getVideoView() { - if (mVideoView == null) { - mVideoView = new VideoView(mRootLayout.getContext()); - FrameLayout.LayoutParams layoutParams = - new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, - DisplayUtils.dpToPx(mRootLayout.getContext(), 220)); - mVideoView.setLayoutParams(layoutParams); - mRootLayout.addView(mVideoView, 0); - - // Attach a mediaController if we are displaying a video. - final MediaController mediaController = new MediaController(mRootLayout.getContext()); - mediaController.setMediaPlayer(mVideoView); - - mVideoView.setMediaController(mediaController); - mediaController.requestFocus(); - mVideoView.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { - @Override - public void onPrepared(MediaPlayer mp) { - // Show the media controls when the video is ready to be played. - mediaController.show(0); - } - }); - } - - return mVideoView; - } - - public void hideImageView() { - if (mImageView != null) { - mImageView.setVisibility(View.GONE); - } - } - - public void hideVideoView() { - if (mVideoView != null) { - mVideoView.setVisibility(View.GONE); - } - } - } -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlock.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlock.kt new file mode 100644 index 000000000000..fe914b8adee3 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlock.kt @@ -0,0 +1,272 @@ +package org.wordpress.android.ui.notifications.blocks + +import android.graphics.drawable.Drawable +import android.net.Uri +import android.text.Spannable +import android.text.TextUtils +import android.text.style.TypefaceSpan +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.MediaController +import android.widget.VideoView +import org.wordpress.android.R +import org.wordpress.android.fluxc.tools.FormattableContent +import org.wordpress.android.fluxc.tools.FormattableMedia +import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper +import org.wordpress.android.util.AccessibilityUtils +import org.wordpress.android.util.DisplayUtils +import org.wordpress.android.util.getMediaOrNull +import org.wordpress.android.util.getMetaIdsSiteIdOrZero +import org.wordpress.android.util.getMetaLinksHomeOrEmpty +import org.wordpress.android.util.getMetaTitlesHomeOrEmpty +import org.wordpress.android.util.getMobileButtonRange +import org.wordpress.android.util.image.GlidePopTransitionOptions.pop +import org.wordpress.android.util.image.ImageManager +import org.wordpress.android.util.image.ImageType +import org.wordpress.android.util.isMobileButton +import org.wordpress.android.widgets.WPTextView + +/** + * A block of data displayed in a notification. + * This basic block can support a media item (image/video) and/or text. + */ +open class NoteBlock( + val noteData: FormattableContent, + @JvmField protected val mImageManager: ImageManager, + @JvmField protected val mNotificationsUtilsWrapper: NotificationsUtilsWrapper, + private val mOnNoteBlockTextClickListener: OnNoteBlockTextClickListener? +) { + protected open var mIsBadge = false + private var isPingBack = false + protected open var mIsViewMilestone = false + + interface OnNoteBlockTextClickListener { + fun onNoteBlockTextClicked(clickedSpan: NoteBlockClickableSpan?) + fun showDetailForNoteIds() + fun showReaderPostComments() + fun showSitePreview(siteId: Long, siteUrl: String?) + fun showActionPopup(view: View) + } + + open val layoutResourceId: Int + get() = R.layout.note_block_basic + val onNoteBlockTextClickListener: OnNoteBlockTextClickListener? + get() = mOnNoteBlockTextClickListener + open val blockType: BlockType? + get() = BlockType.BASIC + open val noteText: Spannable + get() = mNotificationsUtilsWrapper.getSpannableContentForRanges( + noteData, + null, + mOnNoteBlockTextClickListener, + false + ) + val metaHomeTitle: String + get() = noteData.getMetaTitlesHomeOrEmpty() + val metaSiteId: Long + get() = noteData.getMetaIdsSiteIdOrZero() + open val metaSiteUrl: String? + get() = noteData.getMetaLinksHomeOrEmpty() + + val noteMediaItem: FormattableMedia? + get() = noteData.getMediaOrNull(0) + + fun setIsPingback() { + isPingBack = true + } + + fun setIsBadge() { + mIsBadge = true + } + + fun setIsViewMilestone() { + mIsViewMilestone = true + } + + private fun hasMediaArray() = noteData.media != null && noteData.media?.isNotEmpty() == true + + open fun hasImageMediaItem(): Boolean { + return (hasMediaArray() && noteMediaItem != null && !TextUtils.isEmpty(noteMediaItem?.type) + && (noteMediaItem?.type?.startsWith("image") == true || noteMediaItem?.type == "badge") + && !TextUtils.isEmpty(noteMediaItem?.url)) + } + + private fun hasVideoMediaItem(): Boolean { + return (hasMediaArray() && noteMediaItem != null && !TextUtils.isEmpty(noteMediaItem?.type) + && noteMediaItem?.type?.startsWith("video") == true + && !TextUtils.isEmpty(noteMediaItem?.url)) + } + + fun containsBadgeMediaType(): Boolean { + noteData.media?.forEach { + if ("badge" == it.type) { + return true + } + } + return false + } + + open fun configureView(view: View): View { + val noteBlockHolder = view.tag as BasicNoteBlockHolder + + // Note image + if (hasImageMediaItem()) { + noteBlockHolder.imageView.visibility = View.VISIBLE + // Request image, and animate it when loaded + mImageManager.animateWithResultListener( + noteBlockHolder.imageView, ImageType.IMAGE, + noteMediaItem?.url ?: "", + pop(), + object : ImageManager.RequestListener { + override fun onLoadFailed(e: Exception?, model: Any?) { + noteBlockHolder.hideImageView() + } + + override fun onResourceReady(resource: Drawable, model: Any?) { /* no-op */ + } + }) + if (mIsBadge) { + noteBlockHolder.imageView.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } + } else { + mImageManager.cancelRequestAndClearImageView(noteBlockHolder.imageView) + noteBlockHolder.hideImageView() + } + + // Note video + if (hasVideoMediaItem()) { + noteBlockHolder.videoView.setVideoURI(Uri.parse(noteMediaItem?.url ?: "")) + noteBlockHolder.videoView.visibility = View.VISIBLE + } else { + noteBlockHolder.hideVideoView() + } + + // Note text + val noteText = noteText + if (!TextUtils.isEmpty(noteText)) { + if (isPingBack) { + noteBlockHolder.textView.visibility = View.GONE + noteBlockHolder.materialButton.visibility = View.GONE + noteBlockHolder.divider.visibility = View.VISIBLE + noteBlockHolder.button.visibility = View.VISIBLE + noteBlockHolder.button.text = noteText.toString() + noteBlockHolder.button.setOnClickListener { _: View? -> + mOnNoteBlockTextClickListener?.showSitePreview(0, metaSiteUrl) + } + } else { + var textViewVisibility = View.VISIBLE + if (mIsBadge) { + textViewVisibility = handleBadge(noteBlockHolder, view, textViewVisibility, noteText) + } else { + noteBlockHolder.textView.gravity = Gravity.NO_GRAVITY + noteBlockHolder.textView.setPadding(0, 0, 0, 0) + } + val spans = noteText.getSpans(0, noteText.length, NoteBlockClickableSpan::class.java) + for (span in spans) { + span.enableColors(view.context) + } + noteBlockHolder.textView.text = noteText + noteBlockHolder.textView.visibility = textViewVisibility + } + } else { + noteBlockHolder.button.visibility = View.GONE + noteBlockHolder.divider.visibility = View.GONE + noteBlockHolder.materialButton.visibility = View.GONE + noteBlockHolder.textView.visibility = View.GONE + } + return view + } + + @Suppress("MagicNumber") + private fun handleBadge( + noteBlockHolder: BasicNoteBlockHolder, + view: View, + textViewVisibility: Int, + noteText: Spannable + ): Int { + var textViewVisibility1 = textViewVisibility + val params = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.MATCH_PARENT + ) + params.gravity = Gravity.CENTER_HORIZONTAL + noteBlockHolder.textView.layoutParams = params + noteBlockHolder.textView.gravity = Gravity.CENTER_HORIZONTAL + val padding: Int = if (mIsViewMilestone) 40 else 8 + noteBlockHolder.textView.setPadding(0, DisplayUtils.dpToPx(view.context, padding), 0, 0) + if (AccessibilityUtils.isAccessibilityEnabled(noteBlockHolder.textView.context)) { + noteBlockHolder.textView.isClickable = false + noteBlockHolder.textView.isLongClickable = false + } + if (mIsViewMilestone) { + if (noteData.isMobileButton()) { + textViewVisibility1 = View.GONE + noteBlockHolder.button.visibility = View.GONE + noteBlockHolder.materialButton.visibility = View.VISIBLE + noteBlockHolder.materialButton.text = noteText.toString() + noteBlockHolder.materialButton.setOnClickListener { _: View? -> + val buttonRange = noteData.getMobileButtonRange() + if (buttonRange != null) { + val clickableSpan = NoteBlockClickableSpan(buttonRange, true, false) + mOnNoteBlockTextClickListener?.onNoteBlockTextClicked(clickableSpan) + } + } + } else { + noteBlockHolder.textView.textSize = 28f + val typefaceSpan = TypefaceSpan("sans-serif") + noteText.setSpan(typefaceSpan, 0, noteText.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + return textViewVisibility1 + } + + open fun getViewHolder(view: View): Any = BasicNoteBlockHolder(view) + + internal class BasicNoteBlockHolder(view: View) { + private val mRootLayout: LinearLayout = view as LinearLayout + val textView: WPTextView = view.findViewById(R.id.note_text).apply { + movementMethod = NoteBlockLinkMovementMethod() + } + val button: Button = view.findViewById(R.id.note_button) + val materialButton: Button = view.findViewById(R.id.note_material_button) + val divider: View = view.findViewById(R.id.divider_view) + + val imageView: ImageView by lazy { + mRootLayout.findViewById(R.id.image) + } + + @Suppress("MagicNumber") + val videoView: VideoView by lazy { + VideoView(mRootLayout.context).apply { + val layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + DisplayUtils.dpToPx(mRootLayout.context, 220) + ) + this.layoutParams = layoutParams + mRootLayout.addView(this, 0) + + // Attach a mediaController if we are displaying a video. + val mediaController = MediaController(mRootLayout.context) + mediaController.setMediaPlayer(this) + this.setMediaController(mediaController) + mediaController.requestFocus() + this.setOnPreparedListener { // Show the media controls when the video is ready to be played. + mediaController.show(0) + } + } + } + + fun hideImageView() { + imageView.visibility = View.GONE + } + + fun hideVideoView() { + videoView.visibility = View.GONE + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlockClickableSpan.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlockClickableSpan.java deleted file mode 100644 index 50b74fafac8a..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlockClickableSpan.java +++ /dev/null @@ -1,189 +0,0 @@ -package org.wordpress.android.ui.notifications.blocks; - -import android.content.Context; -import android.graphics.Color; -import android.graphics.Typeface; -import android.text.TextPaint; -import android.text.TextUtils; -import android.text.style.ClickableSpan; -import android.view.View; - -import androidx.annotation.ColorInt; -import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; - -import org.wordpress.android.R; -import org.wordpress.android.fluxc.tools.FormattableRange; -import org.wordpress.android.fluxc.tools.FormattableRangeType; -import org.wordpress.android.util.extensions.ContextExtensionsKt; - -import java.util.List; - -/** - * A clickable span that includes extra ids/urls - * Maps to a 'range' in a WordPress.com note object - */ -public class NoteBlockClickableSpan extends ClickableSpan { - private long mId; - private long mSiteId; - private long mPostId; - private FormattableRangeType mRangeType; - private FormattableRange mFormattableRange; - private String mUrl; - private List mIndices; - private boolean mPressed; - private boolean mShouldLink; - private boolean mIsFooter; - - private int mTextColor; - private int mBackgroundColor; - private int mLinkColor; - private int mLightTextColor; - - public NoteBlockClickableSpan(FormattableRange range, boolean shouldLink, boolean isFooter) { - mShouldLink = shouldLink; - mIsFooter = isFooter; - processRangeData(range); - } - - // We need to use theme-styled colors in NoteBlockClickableSpan but current Notifications architecture makes it - // difficult to get right type of context to this span to style the colors. We are doing it in this method instead. - public void enableColors(Context context) { - mTextColor = ContextExtensionsKt.getColorFromAttribute( - context, - com.google.android.material.R.attr.colorOnSurface - ); - mBackgroundColor = ContextCompat.getColor( - context, - R.color.primary_5 - ); - mLinkColor = ContextExtensionsKt.getColorFromAttribute( - context, - com.google.android.material.R.attr.colorPrimary - ); - mLightTextColor = ContextExtensionsKt.getColorFromAttribute( - context, - com.google.android.material.R.attr.colorOnSurface - ); - } - - public void setColors(@ColorInt int textColor, @ColorInt int backgroundColor, @ColorInt int linkColor, - @ColorInt int lightTextColor) { - mTextColor = textColor; - mBackgroundColor = backgroundColor; - mLinkColor = linkColor; - mLightTextColor = lightTextColor; - } - - - private void processRangeData(FormattableRange range) { - if (range != null) { - mFormattableRange = range; - mId = range.getId() == null ? 0 : range.getId(); - mSiteId = range.getSiteId() == null ? 0 : range.getSiteId(); - mPostId = range.getPostId() == null ? 0 : range.getPostId(); - mRangeType = range.rangeType(); - mUrl = range.getUrl(); - mIndices = range.getIndices(); - - mShouldLink = shouldLinkRangeType(); - - // Apply grey color to some types - if (mIsFooter || getRangeType() == FormattableRangeType.BLOCKQUOTE - || getRangeType() == FormattableRangeType.POST) { - mTextColor = mLightTextColor; - } - } - } - - // Don't link certain range types, or unknown ones, unless we have a URL - private boolean shouldLinkRangeType() { - return mShouldLink - && mRangeType != FormattableRangeType.BLOCKQUOTE - && mRangeType != FormattableRangeType.MATCH - && mRangeType != FormattableRangeType.B - && (mRangeType != FormattableRangeType.UNKNOWN || !TextUtils.isEmpty(mUrl)); - } - - @Override - public void updateDrawState(@NonNull TextPaint textPaint) { - // Set background color - textPaint.bgColor = mShouldLink && mPressed && !isBlockquoteType() - ? mBackgroundColor : Color.TRANSPARENT; - textPaint.setColor(mShouldLink && !mIsFooter ? mLinkColor : mTextColor); - // No underlines - textPaint.setUnderlineText(mIsFooter); - } - - private boolean isBlockquoteType() { - return getRangeType() == FormattableRangeType.BLOCKQUOTE; - } - - // return the desired style for this id type - public int getSpanStyle() { - if (mIsFooter) { - return Typeface.BOLD; - } - - switch (getRangeType()) { - case USER: - case MATCH: - case SITE: - case POST: - case COMMENT: - case REWIND_DOWNLOAD_READY: - case B: - return Typeface.BOLD; - case BLOCKQUOTE: - return Typeface.ITALIC; - case STAT: - case FOLLOW: - case NOTICON: - case LIKE: - case UNKNOWN: - default: - return Typeface.NORMAL; - } - } - - @Override - public void onClick(View widget) { - // noop - } - - public FormattableRangeType getRangeType() { - return mRangeType; - } - - public FormattableRange getFormattableRange() { - return mFormattableRange; - } - - public List getIndices() { - return mIndices; - } - - public long getId() { - return mId; - } - - public long getSiteId() { - return mSiteId; - } - - public long getPostId() { - return mPostId; - } - - public void setPressed(boolean isPressed) { - this.mPressed = isPressed; - } - - public String getUrl() { - return mUrl; - } - - public void setCustomType(String type) { - mRangeType = FormattableRangeType.Companion.fromString(type); - } -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlockClickableSpan.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlockClickableSpan.kt new file mode 100644 index 000000000000..660c2d8ec56b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlockClickableSpan.kt @@ -0,0 +1,137 @@ +package org.wordpress.android.ui.notifications.blocks + +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.text.TextPaint +import android.text.TextUtils +import android.text.style.ClickableSpan +import android.view.View +import androidx.annotation.ColorInt +import androidx.core.content.ContextCompat +import org.wordpress.android.R +import org.wordpress.android.fluxc.tools.FormattableRange +import org.wordpress.android.fluxc.tools.FormattableRangeType +import org.wordpress.android.fluxc.tools.FormattableRangeType.Companion.fromString +import org.wordpress.android.util.extensions.getColorFromAttribute + +/** + * A clickable span that includes extra ids/urls + * Maps to a 'range' in a WordPress.com note object + */ +open class NoteBlockClickableSpan( + range: FormattableRange?, + private var mShouldLink: Boolean, + private val mIsFooter: Boolean +) : ClickableSpan() { + var id: Long = 0 + private set + var siteId: Long = 0 + private set + var postId: Long = 0 + private set + var rangeType: FormattableRangeType? = null + private set + var formattableRange: FormattableRange? = null + private set + var url: String? = null + private set + var indices: List? = null + private set + private var mPressed = false + private var mTextColor = 0 + private var mBackgroundColor = 0 + private var mLinkColor = 0 + private var mLightTextColor = 0 + + init { + processRangeData(range) + } + + // We need to use theme-styled colors in NoteBlockClickableSpan but current Notifications architecture makes it + // difficult to get right type of context to this span to style the colors. We are doing it in this method instead. + fun enableColors(context: Context) { + mTextColor = context.getColorFromAttribute(com.google.android.material.R.attr.colorOnSurface) + mBackgroundColor = ContextCompat.getColor(context, R.color.primary_5) + mLinkColor = context.getColorFromAttribute(com.google.android.material.R.attr.colorPrimary) + mLightTextColor = context.getColorFromAttribute(com.google.android.material.R.attr.colorOnSurface) + } + + fun setColors( + @ColorInt textColor: Int, @ColorInt backgroundColor: Int, @ColorInt linkColor: Int, + @ColorInt lightTextColor: Int + ) { + mTextColor = textColor + mBackgroundColor = backgroundColor + mLinkColor = linkColor + mLightTextColor = lightTextColor + } + + private fun processRangeData(range: FormattableRange?) { + if (range != null) { + formattableRange = range + id = range.id ?: 0 + siteId = range.siteId ?: 0 + postId = range.postId ?: 0 + rangeType = range.rangeType() + url = range.url + this.indices = range.indices + mShouldLink = shouldLinkRangeType() + + // Apply grey color to some types + if (mIsFooter || rangeType == FormattableRangeType.BLOCKQUOTE || rangeType == FormattableRangeType.POST) { + mTextColor = mLightTextColor + } + } + } + + // Don't link certain range types, or unknown ones, unless we have a URL + private fun shouldLinkRangeType() = mShouldLink && + rangeType != FormattableRangeType.BLOCKQUOTE && + rangeType != FormattableRangeType.MATCH && + rangeType != FormattableRangeType.B && + (rangeType != FormattableRangeType.UNKNOWN || !TextUtils.isEmpty(url)) + + override fun updateDrawState(textPaint: TextPaint) { + // Set background color + textPaint.bgColor = if (mShouldLink && mPressed && !isBlockquoteType) mBackgroundColor else Color.TRANSPARENT + textPaint.color = if (mShouldLink && !mIsFooter) mLinkColor else mTextColor + // No underlines + textPaint.isUnderlineText = mIsFooter + } + + private val isBlockquoteType: Boolean + get() = rangeType == FormattableRangeType.BLOCKQUOTE + val spanStyle: Int + // return the desired style for this id type + get() = if (mIsFooter) { + Typeface.BOLD + } else when (rangeType) { + FormattableRangeType.USER, + FormattableRangeType.MATCH, + FormattableRangeType.SITE, + FormattableRangeType.POST, + FormattableRangeType.COMMENT, + FormattableRangeType.REWIND_DOWNLOAD_READY, + FormattableRangeType.B -> Typeface.BOLD + FormattableRangeType.BLOCKQUOTE -> Typeface.ITALIC + FormattableRangeType.STAT, + FormattableRangeType.FOLLOW, + FormattableRangeType.NOTICON, + FormattableRangeType.LIKE, + FormattableRangeType.UNKNOWN -> Typeface.NORMAL + else -> Typeface.NORMAL + } + + override fun onClick(widget: View) { + // noop + } + + fun setPressed(isPressed: Boolean) { + mPressed = isPressed + } + + fun setCustomType(type: String?) { + rangeType = fromString(type) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlockLinkMovementMethod.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlockLinkMovementMethod.java deleted file mode 100644 index c33b8d844b67..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlockLinkMovementMethod.java +++ /dev/null @@ -1,68 +0,0 @@ -package org.wordpress.android.ui.notifications.blocks; - -import android.text.Layout; -import android.text.Selection; -import android.text.Spannable; -import android.text.method.LinkMovementMethod; -import android.view.MotionEvent; -import android.widget.TextView; - -import androidx.annotation.NonNull; - -/** - * Allows links to be highlighted when tapped on note blocks. - * See: http://stackoverflow.com/a/20905824/309558 - */ -public class NoteBlockLinkMovementMethod extends LinkMovementMethod { - private NoteBlockClickableSpan mPressedSpan; - - @Override - public boolean onTouchEvent(@NonNull TextView textView, @NonNull Spannable spannable, @NonNull MotionEvent event) { - if (event.getAction() == MotionEvent.ACTION_DOWN) { - mPressedSpan = getPressedSpan(textView, spannable, event); - if (mPressedSpan != null) { - mPressedSpan.setPressed(true); - Selection.setSelection(spannable, spannable.getSpanStart(mPressedSpan), - spannable.getSpanEnd(mPressedSpan)); - } - } else if (event.getAction() == MotionEvent.ACTION_MOVE) { - NoteBlockClickableSpan touchedSpan = getPressedSpan(textView, spannable, event); - if (mPressedSpan != null && touchedSpan != mPressedSpan) { - mPressedSpan.setPressed(false); - mPressedSpan = null; - Selection.removeSelection(spannable); - } - } else { - if (mPressedSpan != null) { - mPressedSpan.setPressed(false); - super.onTouchEvent(textView, spannable, event); - } - mPressedSpan = null; - Selection.removeSelection(spannable); - } - return true; - } - - private NoteBlockClickableSpan getPressedSpan(TextView textView, Spannable spannable, MotionEvent event) { - int x = (int) event.getX(); - int y = (int) event.getY(); - - x -= textView.getTotalPaddingLeft(); - y -= textView.getTotalPaddingTop(); - - x += textView.getScrollX(); - y += textView.getScrollY(); - - Layout layout = textView.getLayout(); - int line = layout.getLineForVertical(y); - int off = layout.getOffsetForHorizontal(line, x); - - NoteBlockClickableSpan[] link = spannable.getSpans(off, off, NoteBlockClickableSpan.class); - NoteBlockClickableSpan touchedSpan = null; - if (link.length > 0) { - touchedSpan = link[0]; - } - - return touchedSpan; - } -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlockLinkMovementMethod.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlockLinkMovementMethod.kt new file mode 100644 index 000000000000..15b9c4faee6c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/NoteBlockLinkMovementMethod.kt @@ -0,0 +1,60 @@ +package org.wordpress.android.ui.notifications.blocks + +import android.text.Selection +import android.text.Spannable +import android.text.method.LinkMovementMethod +import android.view.MotionEvent +import android.widget.TextView + +/** + * Allows links to be highlighted when tapped on note blocks. + * See: http://stackoverflow.com/a/20905824/309558 + */ +class NoteBlockLinkMovementMethod : LinkMovementMethod() { + private var mPressedSpan: NoteBlockClickableSpan? = null + override fun onTouchEvent(textView: TextView, spannable: Spannable, event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + mPressedSpan = getPressedSpan(textView, spannable, event) + if (mPressedSpan != null) { + mPressedSpan?.setPressed(true) + Selection.setSelection( + spannable, spannable.getSpanStart(mPressedSpan), + spannable.getSpanEnd(mPressedSpan) + ) + } + } + + MotionEvent.ACTION_MOVE -> { + val touchedSpan = getPressedSpan(textView, spannable, event) + if (mPressedSpan != null && touchedSpan !== mPressedSpan) { + mPressedSpan?.setPressed(false) + mPressedSpan = null + Selection.removeSelection(spannable) + } + } + + else -> { + if (mPressedSpan != null) { + mPressedSpan?.setPressed(false) + super.onTouchEvent(textView, spannable, event) + } + mPressedSpan = null + Selection.removeSelection(spannable) + } + } + return true + } + + private fun getPressedSpan(textView: TextView, spannable: Spannable, event: MotionEvent): NoteBlockClickableSpan? { + val x = event.x + textView.scrollX - textView.totalPaddingLeft + val y = event.y + textView.scrollY - textView.totalPaddingTop + val line = textView.layout.getLineForVertical(y.toInt()) + val off = textView.layout.getOffsetForHorizontal(line, x) + val link = spannable.getSpans(off, off, NoteBlockClickableSpan::class.java) + if (link.isNotEmpty()) { + return link[0] + } + return null + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/UserNoteBlock.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/UserNoteBlock.java deleted file mode 100644 index 8702d938f7f6..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/UserNoteBlock.java +++ /dev/null @@ -1,211 +0,0 @@ -package org.wordpress.android.ui.notifications.blocks; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.text.TextUtils; -import android.view.MotionEvent; -import android.view.View; -import android.view.animation.DecelerateInterpolator; -import android.widget.ImageView; -import android.widget.TextView; - -import org.wordpress.android.R; -import org.wordpress.android.fluxc.tools.FormattableContent; -import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper; -import org.wordpress.android.util.FormattableContentUtilsKt; -import org.wordpress.android.util.WPAvatarUtils; -import org.wordpress.android.util.image.ImageManager; -import org.wordpress.android.util.image.ImageType; - -/** - * A block that displays information about a User (such as a user that liked a post) - */ -public class UserNoteBlock extends NoteBlock { - private final OnGravatarClickedListener mGravatarClickedListener; - - private int mAvatarSz; - - public interface OnGravatarClickedListener { - // userId is currently unused, but will be handy once a profile view is added to the app - void onGravatarClicked(long siteId, long userId, String siteUrl); - } - - public UserNoteBlock( - Context context, - FormattableContent noteObject, - OnNoteBlockTextClickListener onNoteBlockTextClickListener, - OnGravatarClickedListener onGravatarClickedListener, - ImageManager imageManager, - NotificationsUtilsWrapper notificationsUtilsWrapper) { - super(noteObject, imageManager, notificationsUtilsWrapper, onNoteBlockTextClickListener); - - if (context != null) { - setAvatarSize(context.getResources().getDimensionPixelSize(R.dimen.notifications_avatar_sz)); - } - mGravatarClickedListener = onGravatarClickedListener; - } - - void setAvatarSize(int size) { - mAvatarSz = size; - } - - int getAvatarSize() { - return mAvatarSz; - } - - @Override - public BlockType getBlockType() { - return BlockType.USER; - } - - @Override - public int getLayoutResourceId() { - return R.layout.note_block_user; - } - - @SuppressLint("ClickableViewAccessibility") // fixed by setting a click listener to avatarImageView - @Override - public View configureView(View view) { - final UserActionNoteBlockHolder noteBlockHolder = (UserActionNoteBlockHolder) view.getTag(); - noteBlockHolder.mNameTextView.setText(getNoteText().toString()); - - - String linkedText = null; - if (hasUserUrlAndTitle()) { - linkedText = getUserBlogTitle(); - } else if (hasUserUrl()) { - linkedText = getUserUrl(); - } - - if (!TextUtils.isEmpty(linkedText)) { - noteBlockHolder.mUrlTextView.setText(linkedText); - noteBlockHolder.mUrlTextView.setVisibility(View.VISIBLE); - } else { - noteBlockHolder.mUrlTextView.setVisibility(View.GONE); - } - - if (hasUserBlogTagline()) { - noteBlockHolder.mTaglineTextView.setText(getUserBlogTagline()); - noteBlockHolder.mTaglineTextView.setVisibility(View.VISIBLE); - } else { - noteBlockHolder.mTaglineTextView.setVisibility(View.GONE); - } - - String imageUrl = ""; - if (hasImageMediaItem()) { - imageUrl = WPAvatarUtils.rewriteAvatarUrl(getNoteMediaItem().getUrl(), getAvatarSize()); - if (!TextUtils.isEmpty(getUserUrl())) { - //noinspection AndroidLintClickableViewAccessibility - noteBlockHolder.mAvatarImageView.setOnTouchListener(mOnGravatarTouchListener); - noteBlockHolder.mRootView.setEnabled(true); - noteBlockHolder.mRootView.setOnClickListener(mOnClickListener); - } else { - //noinspection AndroidLintClickableViewAccessibility - noteBlockHolder.mAvatarImageView.setOnTouchListener(null); - noteBlockHolder.mRootView.setEnabled(false); - noteBlockHolder.mRootView.setOnClickListener(null); - } - } else { - noteBlockHolder.mRootView.setEnabled(false); - noteBlockHolder.mRootView.setOnClickListener(null); - //noinspection AndroidLintClickableViewAccessibility - noteBlockHolder.mAvatarImageView.setOnTouchListener(null); - } - mImageManager.loadIntoCircle(noteBlockHolder.mAvatarImageView, ImageType.AVATAR_WITH_BACKGROUND, imageUrl); - - return view; - } - - private final View.OnClickListener mOnClickListener = new View.OnClickListener() { - @Override - public void onClick(View v) { - showBlogPreview(); - } - }; - - @Override - public Object getViewHolder(View view) { - return new UserActionNoteBlockHolder(view); - } - - private class UserActionNoteBlockHolder { - private final View mRootView; - private final TextView mNameTextView; - private final TextView mUrlTextView; - private final TextView mTaglineTextView; - private final ImageView mAvatarImageView; - - UserActionNoteBlockHolder(View view) { - mRootView = view.findViewById(R.id.user_block_root_view); - mNameTextView = view.findViewById(R.id.user_name); - mUrlTextView = view.findViewById(R.id.user_blog_url); - mTaglineTextView = view.findViewById(R.id.user_blog_tagline); - mAvatarImageView = view.findViewById(R.id.user_avatar); - } - } - - String getUserUrl() { - return FormattableContentUtilsKt.getMetaLinksHomeOrEmpty(getNoteData()); - } - - private String getUserBlogTitle() { - return FormattableContentUtilsKt.getMetaTitlesHomeOrEmpty(getNoteData()); - } - - private String getUserBlogTagline() { - return FormattableContentUtilsKt.getMetaTitlesTaglineOrEmpty(getNoteData()); - } - - private boolean hasUserUrl() { - return !TextUtils.isEmpty(getUserUrl()); - } - - private boolean hasUserUrlAndTitle() { - return hasUserUrl() && !TextUtils.isEmpty(getUserBlogTitle()); - } - - private boolean hasUserBlogTagline() { - return !TextUtils.isEmpty(getUserBlogTagline()); - } - - final View.OnTouchListener mOnGravatarTouchListener = new View.OnTouchListener() { - @Override - public boolean onTouch(View v, MotionEvent event) { - int animationDuration = 150; - - if (event.getAction() == MotionEvent.ACTION_DOWN) { - v.animate() - .scaleX(0.9f) - .scaleY(0.9f) - .alpha(0.5f) - .setDuration(animationDuration) - .setInterpolator(new DecelerateInterpolator()); - } else if (event.getActionMasked() == MotionEvent.ACTION_UP - || event.getActionMasked() == MotionEvent.ACTION_CANCEL) { - v.animate() - .scaleX(1.0f) - .scaleY(1.0f) - .alpha(1.0f) - .setDuration(animationDuration) - .setInterpolator(new DecelerateInterpolator()); - - if (event.getActionMasked() == MotionEvent.ACTION_UP && mGravatarClickedListener != null) { - // Fire the listener, which will load the site preview for the user's site - // In the future we can use this to load a 'profile view' (currently in R&D) - v.performClick(); - } - } - - return true; - } - }; - - protected void showBlogPreview() { - String siteUrl = getUserUrl(); - if (mGravatarClickedListener != null) { - mGravatarClickedListener - .onGravatarClicked(FormattableContentUtilsKt.getMetaIdsSiteIdOrZero(getNoteData()), - FormattableContentUtilsKt.getMetaIdsUserIdOrZero(getNoteData()), siteUrl); - } - } -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/UserNoteBlock.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/UserNoteBlock.kt new file mode 100644 index 000000000000..738283e2d486 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/blocks/UserNoteBlock.kt @@ -0,0 +1,165 @@ +package org.wordpress.android.ui.notifications.blocks + +import android.annotation.SuppressLint +import android.content.Context +import android.text.TextUtils +import android.view.MotionEvent +import android.view.View +import android.view.View.OnTouchListener +import android.view.animation.DecelerateInterpolator +import android.widget.ImageView +import android.widget.TextView +import org.wordpress.android.R +import org.wordpress.android.fluxc.tools.FormattableContent +import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper +import org.wordpress.android.util.WPAvatarUtils +import org.wordpress.android.util.getMetaIdsSiteIdOrZero +import org.wordpress.android.util.getMetaIdsUserIdOrZero +import org.wordpress.android.util.getMetaLinksHomeOrEmpty +import org.wordpress.android.util.getMetaTitlesHomeOrEmpty +import org.wordpress.android.util.getMetaTitlesTaglineOrEmpty +import org.wordpress.android.util.image.ImageManager +import org.wordpress.android.util.image.ImageType + +/** + * A block that displays information about a User (such as a user that liked a post) + */ +open class UserNoteBlock( + context: Context?, + noteObject: FormattableContent, + onNoteBlockTextClickListener: OnNoteBlockTextClickListener?, + onGravatarClickedListener: OnGravatarClickedListener?, + imageManager: ImageManager, + notificationsUtilsWrapper: NotificationsUtilsWrapper +) : NoteBlock(noteObject, imageManager, notificationsUtilsWrapper, onNoteBlockTextClickListener) { + private val mGravatarClickedListener: OnGravatarClickedListener? + var avatarSize: Int + + override val blockType: BlockType + get() = BlockType.USER + override val layoutResourceId: Int + get() = R.layout.note_block_user + + val userUrl: String + get() = noteData.getMetaLinksHomeOrEmpty() + private val userBlogTitle: String + get() = noteData.getMetaTitlesHomeOrEmpty() + private val userBlogTagline: String + get() = noteData.getMetaTitlesTaglineOrEmpty() + + private val mOnClickListener = View.OnClickListener { showBlogPreview() } + + init { + avatarSize = context?.resources?.getDimensionPixelSize(R.dimen.notifications_avatar_sz) ?: 0 + mGravatarClickedListener = onGravatarClickedListener + } + + interface OnGravatarClickedListener { + // userId is currently unused, but will be handy once a profile view is added to the app + fun onGravatarClicked(siteId: Long, userId: Long, siteUrl: String?) + } + + @SuppressLint("ClickableViewAccessibility") // fixed by setting a click listener to avatarImageView + override fun configureView(view: View): View { + val noteBlockHolder = view.tag as UserActionNoteBlockHolder + noteBlockHolder.mNameTextView.text = noteText.toString() + var linkedText: String? = null + if (hasUserUrlAndTitle()) { + linkedText = userBlogTitle + } else if (hasUserUrl()) { + linkedText = userUrl + } + if (!TextUtils.isEmpty(linkedText)) { + noteBlockHolder.mUrlTextView.text = linkedText + noteBlockHolder.mUrlTextView.visibility = View.VISIBLE + } else { + noteBlockHolder.mUrlTextView.visibility = View.GONE + } + if (hasUserBlogTagline()) { + noteBlockHolder.mTaglineTextView.text = userBlogTagline + noteBlockHolder.mTaglineTextView.visibility = View.VISIBLE + } else { + noteBlockHolder.mTaglineTextView.visibility = View.GONE + } + var imageUrl = "" + if (hasImageMediaItem()) { + noteMediaItem?.url?.let { + imageUrl = WPAvatarUtils.rewriteAvatarUrl(it, avatarSize) + } + if (!TextUtils.isEmpty(userUrl)) { + noteBlockHolder.mAvatarImageView.setOnTouchListener(mOnGravatarTouchListener) + noteBlockHolder.mRootView.isEnabled = true + noteBlockHolder.mRootView.setOnClickListener(mOnClickListener) + } else { + noteBlockHolder.mAvatarImageView.setOnTouchListener(null) + noteBlockHolder.mRootView.isEnabled = false + noteBlockHolder.mRootView.setOnClickListener(null) + } + } else { + noteBlockHolder.mRootView.isEnabled = false + noteBlockHolder.mRootView.setOnClickListener(null) + noteBlockHolder.mAvatarImageView.setOnTouchListener(null) + } + mImageManager.loadIntoCircle( + noteBlockHolder.mAvatarImageView, + ImageType.AVATAR_WITH_BACKGROUND, + imageUrl + ) + return view + } + + override fun getViewHolder(view: View): Any = UserActionNoteBlockHolder(view) + + private inner class UserActionNoteBlockHolder(view: View) { + val mRootView: View = view.findViewById(R.id.user_block_root_view) + val mNameTextView: TextView = view.findViewById(R.id.user_name) + val mUrlTextView: TextView = view.findViewById(R.id.user_blog_url) + val mTaglineTextView: TextView = view.findViewById(R.id.user_blog_tagline) + val mAvatarImageView: ImageView = view.findViewById(R.id.user_avatar) + } + + private fun hasUserUrl(): Boolean = !TextUtils.isEmpty(userUrl) + + private fun hasUserUrlAndTitle(): Boolean = hasUserUrl() && !TextUtils.isEmpty(userBlogTitle) + + private fun hasUserBlogTagline(): Boolean = !TextUtils.isEmpty(userBlogTagline) + + @Suppress("MagicNumber") + @JvmField + val mOnGravatarTouchListener = OnTouchListener { v, event -> + val animationDuration = 150 + when (event.action) { + MotionEvent.ACTION_DOWN -> { + v.animate() + .scaleX(0.9f) + .scaleY(0.9f) + .alpha(0.5f) + .setDuration(animationDuration.toLong()) + .setInterpolator(DecelerateInterpolator()) + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + v.animate() + .scaleX(1.0f) + .scaleY(1.0f) + .alpha(1.0f) + .setDuration(animationDuration.toLong()) + .setInterpolator(DecelerateInterpolator()) + if (event.actionMasked == MotionEvent.ACTION_UP && mGravatarClickedListener != null) { + // Fire the listener, which will load the site preview for the user's site + // In the future we can use this to load a 'profile view' (currently in R&D) + v.performClick() + } + } + } + true + } + + protected fun showBlogPreview() { + val siteUrl = userUrl + mGravatarClickedListener?.onGravatarClicked( + noteData.getMetaIdsSiteIdOrZero(), + noteData.getMetaIdsUserIdOrZero(), siteUrl + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.kt index 12898f1f38de..51a7c3dd10f2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.kt @@ -928,6 +928,8 @@ class ReaderPostDetailFragment : ViewPagerFragment(), activity, this.siteId, this.postId, + this.postTitle, + this.postUrl, this.headerData, EngagementNavigationSource.LIKE_READER_LIST ) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderNavigationEvents.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderNavigationEvents.kt index b96ef3fe0123..f7340ec625b2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderNavigationEvents.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderNavigationEvents.kt @@ -65,6 +65,8 @@ sealed class ReaderNavigationEvents { data class ShowEngagedPeopleList( val siteId: Long, val postId: Long, + val postTitle: String, + val postUrl: String, val headerData: HeaderData ) : ReaderNavigationEvents() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/tracker/ReaderTracker.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/tracker/ReaderTracker.kt index 5cc3c683b3a8..06059b1ffa55 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/tracker/ReaderTracker.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/tracker/ReaderTracker.kt @@ -480,6 +480,8 @@ class ReaderTracker @Inject constructor( const val SOURCE_READER_LIKE_LIST = "reader_like_list" const val SOURCE_READER_LIKE_LIST_USER_PROFILE = "reader_like_list_user_profile" const val SOURCE_NOTIF_LIKE_LIST_USER_PROFILE = "notif_like_list_user_profile" + const val SOURCE_SITE_COMMENTS_USER_PROFILE = "site_comments_user_profile" + const val SOURCE_NOTIF_COMMENT_USER_PROFILE = "notif_comment_user_profile" const val SOURCE_USER_PROFILE_UNKNOWN = "user_profile_source_unknown" const val SOURCE_ACTIVITY_LOG_DETAIL = "activity_log_detail" const val SOURCE_BLOGGING_PROMPTS_VIEW_ANSWERS = "blogging_prompts_my_site_card_view_answers" @@ -499,11 +501,14 @@ class ReaderTracker @Inject constructor( AnalyticsTracker.track(stat, properties) } - fun isUserProfileSource(source: String): Boolean { - return (source == SOURCE_READER_LIKE_LIST_USER_PROFILE || - source == SOURCE_NOTIF_LIKE_LIST_USER_PROFILE || - source == SOURCE_USER_PROFILE_UNKNOWN) - } + fun isUserProfileSource(source: String): Boolean = + listOf( + SOURCE_READER_LIKE_LIST_USER_PROFILE, + SOURCE_NOTIF_LIKE_LIST_USER_PROFILE, + SOURCE_SITE_COMMENTS_USER_PROFILE, + SOURCE_NOTIF_COMMENT_USER_PROFILE, + SOURCE_USER_PROFILE_UNKNOWN + ).contains(source) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModel.kt index 884564b2d79d..d36bb5b88e57 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostDetailViewModel.kt @@ -837,6 +837,8 @@ class ReaderPostDetailViewModel @Inject constructor( ShowEngagedPeopleList( readerPost.blogId, readerPost.postId, + readerPost.title, + readerPost.shortUrl, HeaderData( AuthorNameString(readerPost.authorName), readerPost.title, diff --git a/WordPress/src/main/res/anim/notifications_button_scale.xml b/WordPress/src/main/res/anim/notifications_button_scale.xml deleted file mode 100644 index f95e66bd4a05..000000000000 --- a/WordPress/src/main/res/anim/notifications_button_scale.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/WordPress/src/main/res/drawable-hdpi/ic_time_neutral_400_16dp.png b/WordPress/src/main/res/drawable-hdpi/ic_time_neutral_400_16dp.png deleted file mode 100644 index f09acf8570ec..000000000000 Binary files a/WordPress/src/main/res/drawable-hdpi/ic_time_neutral_400_16dp.png and /dev/null differ diff --git a/WordPress/src/main/res/drawable-ldrtl/bg_rectangle_warning_surface.xml b/WordPress/src/main/res/drawable-ldrtl/bg_rectangle_warning_surface.xml deleted file mode 100644 index 1f246053cd9f..000000000000 --- a/WordPress/src/main/res/drawable-ldrtl/bg_rectangle_warning_surface.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - diff --git a/WordPress/src/main/res/drawable-ldrtl/bg_rectangle_warning_surface_with_padding.xml b/WordPress/src/main/res/drawable-ldrtl/bg_rectangle_warning_surface_with_padding.xml deleted file mode 100644 index e16836bd0485..000000000000 --- a/WordPress/src/main/res/drawable-ldrtl/bg_rectangle_warning_surface_with_padding.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/src/main/res/drawable-ldrtl/comment_reply_background.xml b/WordPress/src/main/res/drawable-ldrtl/comment_reply_background.xml deleted file mode 100644 index 6959a71d6bd1..000000000000 --- a/WordPress/src/main/res/drawable-ldrtl/comment_reply_background.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/WordPress/src/main/res/drawable-xhdpi/ic_time_neutral_400_16dp.png b/WordPress/src/main/res/drawable-xhdpi/ic_time_neutral_400_16dp.png deleted file mode 100644 index 83fb565331a8..000000000000 Binary files a/WordPress/src/main/res/drawable-xhdpi/ic_time_neutral_400_16dp.png and /dev/null differ diff --git a/WordPress/src/main/res/drawable-xxhdpi/ic_time_neutral_400_16dp.png b/WordPress/src/main/res/drawable-xxhdpi/ic_time_neutral_400_16dp.png deleted file mode 100644 index ca90db846b9a..000000000000 Binary files a/WordPress/src/main/res/drawable-xxhdpi/ic_time_neutral_400_16dp.png and /dev/null differ diff --git a/WordPress/src/main/res/drawable/arrow_down.xml b/WordPress/src/main/res/drawable/arrow_down.xml new file mode 100644 index 000000000000..a662968d2236 --- /dev/null +++ b/WordPress/src/main/res/drawable/arrow_down.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/WordPress/src/main/res/drawable/arrow_right.xml b/WordPress/src/main/res/drawable/arrow_right.xml new file mode 100644 index 000000000000..ddb6315e4314 --- /dev/null +++ b/WordPress/src/main/res/drawable/arrow_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/WordPress/src/main/res/drawable/baseline_check_16.xml b/WordPress/src/main/res/drawable/baseline_check_16.xml new file mode 100644 index 000000000000..dac4eacb0692 --- /dev/null +++ b/WordPress/src/main/res/drawable/baseline_check_16.xml @@ -0,0 +1,5 @@ + + + diff --git a/WordPress/src/main/res/drawable/bg_rectangle_warning_surface.xml b/WordPress/src/main/res/drawable/bg_rectangle_warning_surface.xml deleted file mode 100644 index 14f35576d8f6..000000000000 --- a/WordPress/src/main/res/drawable/bg_rectangle_warning_surface.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - diff --git a/WordPress/src/main/res/drawable/bg_rectangle_warning_surface_with_padding.xml b/WordPress/src/main/res/drawable/bg_rectangle_warning_surface_with_padding.xml deleted file mode 100644 index a7d384a6dda9..000000000000 --- a/WordPress/src/main/res/drawable/bg_rectangle_warning_surface_with_padding.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/src/main/res/drawable/chevron_right_small.xml b/WordPress/src/main/res/drawable/chevron_right_small.xml new file mode 100644 index 000000000000..1fec4c8d19fd --- /dev/null +++ b/WordPress/src/main/res/drawable/chevron_right_small.xml @@ -0,0 +1,9 @@ + + + diff --git a/WordPress/src/main/res/drawable/comment_author_avatar.xml b/WordPress/src/main/res/drawable/comment_author_avatar.xml new file mode 100644 index 000000000000..e0cad028e9fe --- /dev/null +++ b/WordPress/src/main/res/drawable/comment_author_avatar.xml @@ -0,0 +1,10 @@ + + + diff --git a/WordPress/src/main/res/drawable/comment_reply_background.xml b/WordPress/src/main/res/drawable/comment_reply_background.xml deleted file mode 100644 index 8e62422e2933..000000000000 --- a/WordPress/src/main/res/drawable/comment_reply_background.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/WordPress/src/main/res/drawable/delete_24.xml b/WordPress/src/main/res/drawable/delete_24.xml new file mode 100644 index 000000000000..639a44eaa68e --- /dev/null +++ b/WordPress/src/main/res/drawable/delete_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/WordPress/src/main/res/drawable/edit.xml b/WordPress/src/main/res/drawable/edit.xml new file mode 100644 index 000000000000..fd70b32a1b5e --- /dev/null +++ b/WordPress/src/main/res/drawable/edit.xml @@ -0,0 +1,14 @@ + + + + diff --git a/WordPress/src/main/res/drawable/error_outline_24.xml b/WordPress/src/main/res/drawable/error_outline_24.xml new file mode 100644 index 000000000000..885238a3445d --- /dev/null +++ b/WordPress/src/main/res/drawable/error_outline_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/WordPress/src/main/res/drawable/ic_time_16dp.xml b/WordPress/src/main/res/drawable/ic_time_16dp.xml new file mode 100644 index 000000000000..ec092be39f2a --- /dev/null +++ b/WordPress/src/main/res/drawable/ic_time_16dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/WordPress/src/main/res/drawable/notifications_list_divider_full_width.xml b/WordPress/src/main/res/drawable/notifications_list_divider_full_width.xml deleted file mode 100644 index 57341d697972..000000000000 --- a/WordPress/src/main/res/drawable/notifications_list_divider_full_width.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - diff --git a/WordPress/src/main/res/drawable/trash_24.xml b/WordPress/src/main/res/drawable/trash_24.xml new file mode 100644 index 000000000000..1608306c67d6 --- /dev/null +++ b/WordPress/src/main/res/drawable/trash_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/WordPress/src/main/res/drawable/watch_later_24.xml b/WordPress/src/main/res/drawable/watch_later_24.xml new file mode 100644 index 000000000000..8ce3325bff4e --- /dev/null +++ b/WordPress/src/main/res/drawable/watch_later_24.xml @@ -0,0 +1,6 @@ + + + + diff --git a/WordPress/src/main/res/layout/comment_action_footer.xml b/WordPress/src/main/res/layout/comment_action_footer.xml deleted file mode 100644 index fb683870028a..000000000000 --- a/WordPress/src/main/res/layout/comment_action_footer.xml +++ /dev/null @@ -1,140 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/src/main/res/layout/comment_actions.xml b/WordPress/src/main/res/layout/comment_actions.xml new file mode 100644 index 000000000000..bf173c4ddd05 --- /dev/null +++ b/WordPress/src/main/res/layout/comment_actions.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/src/main/res/layout/comment_approved.xml b/WordPress/src/main/res/layout/comment_approved.xml new file mode 100644 index 000000000000..52afb19ce186 --- /dev/null +++ b/WordPress/src/main/res/layout/comment_approved.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/src/main/res/layout/comment_detail_fragment.xml b/WordPress/src/main/res/layout/comment_detail_fragment.xml index e7f39c3f815b..e26ec29c670f 100644 --- a/WordPress/src/main/res/layout/comment_detail_fragment.xml +++ b/WordPress/src/main/res/layout/comment_detail_fragment.xml @@ -4,18 +4,121 @@ comment detail displayed from both the notification list and the comment list --> - + + + + + + + + + + + + + + + + + + + + + + + + + android:layout_height="0dp" + android:fillViewport="true" + app:layout_constraintBottom_toTopOf="@+id/layout_bottom" + app:layout_constraintTop_toBottomOf="@+id/header_view"> - + android:layout_marginStart="@dimen/margin_extra_large" + android:layout_marginTop="@dimen/margin_extra_small_large"> + android:layout_width="@dimen/avatar_sz_medium" + android:layout_height="@dimen/avatar_sz_medium" + android:importantForAccessibility="no" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> - + android:layout_marginEnd="@dimen/margin_small" + android:background="?attr/selectableItemBackgroundBorderless" + android:contentDescription="@string/more" + android:padding="@dimen/margin_large" + android:src="@drawable/gb_ic_more_horizontal" + app:layout_constraintBottom_toBottomOf="@+id/text_site" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="@+id/text_name" + app:tint="@color/menu_more" /> + android:textAppearance="?attr/textAppearanceBody2" + android:textColor="@color/material_on_surface_emphasis_high_type" + app:layout_constraintBottom_toTopOf="@+id/text_site" + app:layout_constraintEnd_toStartOf="@+id/image_more" + app:layout_constraintStart_toEndOf="@+id/image_avatar" + app:layout_constraintTop_toTopOf="@+id/image_avatar" + tools:text="Bob Ross" /> - - + android:layout_marginStart="@dimen/margin_extra_large" + android:ellipsize="end" + android:singleLine="true" + android:textAlignment="viewStart" + android:textAppearance="?attr/textAppearanceBody2" + android:textColor="@color/material_on_surface_emphasis_medium" + app:layout_constraintBottom_toBottomOf="@+id/image_avatar" + app:layout_constraintEnd_toStartOf="@+id/image_more" + app:layout_constraintStart_toEndOf="@+id/image_avatar" + app:layout_constraintTop_toBottomOf="@+id/text_name" + tools:text="bobross" /> + - - - - - - - - - - - - - + android:lines="1" + android:paddingVertical="@dimen/page_list_row_vertical_padding" + android:textAlignment="viewStart" + android:textColor="?attr/colorOnSurface" + android:textSize="@dimen/text_sz_medium" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@+id/header_arrow" + app:layout_constraintStart_toEndOf="@+id/header_avatar" + app:layout_constraintTop_toTopOf="parent" + app:layout_goneMarginStart="@dimen/margin_extra_large" + tools:text="Snippet" /> + + + diff --git a/WordPress/src/main/res/layout/note_block_milestone.xml b/WordPress/src/main/res/layout/note_block_milestone.xml new file mode 100644 index 000000000000..33c55a87f335 --- /dev/null +++ b/WordPress/src/main/res/layout/note_block_milestone.xml @@ -0,0 +1,63 @@ + + + + + + + +