From e13eaf5ae5951eba465074ede25cc851cf54d595 Mon Sep 17 00:00:00 2001 From: Grishka Date: Thu, 17 Feb 2022 00:31:01 +0300 Subject: [PATCH] Content warnings --- .../fragments/BaseStatusListFragment.java | 74 ++ .../joinmastodon/android/model/Status.java | 4 + .../android/ui/BetterItemAnimator.java | 670 ++++++++++++++++++ .../displayitems/HeaderStatusDisplayItem.java | 27 +- .../displayitems/ImageStatusDisplayItem.java | 29 +- .../ui/displayitems/StatusDisplayItem.java | 10 +- .../displayitems/TextStatusDisplayItem.java | 30 +- .../drawables/BlurhashCrossfadeDrawable.java | 130 ++++ .../android/ui/utils/BlurHashDrawable.java | 6 +- .../src/main/res/drawable/ic_visibility.xml | 5 + .../main/res/drawable/ic_visibility_off.xml | 5 + .../main/res/layout/display_item_header.xml | 12 + .../src/main/res/layout/display_item_text.xml | 25 + mastodon/src/main/res/values/strings.xml | 1 + 14 files changed, 1008 insertions(+), 20 deletions(-) create mode 100644 mastodon/src/main/java/org/joinmastodon/android/ui/BetterItemAnimator.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/ui/drawables/BlurhashCrossfadeDrawable.java create mode 100644 mastodon/src/main/res/drawable/ic_visibility.xml create mode 100644 mastodon/src/main/res/drawable/ic_visibility_off.xml diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java index d905444f..a86d0f4e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java @@ -8,6 +8,7 @@ import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Bundle; +import android.text.TextUtils; import android.util.Log; import android.view.View; import android.view.ViewGroup; @@ -21,12 +22,15 @@ import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.DisplayItemsParent; import org.joinmastodon.android.model.Poll; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.BetterItemAnimator; import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.ImageStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.PhotoStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.PollFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.PollOptionStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; import org.joinmastodon.android.ui.photoviewer.PhotoViewer; import org.joinmastodon.android.ui.photoviewer.PhotoViewerHost; @@ -34,9 +38,11 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.api.Callback; @@ -283,6 +289,7 @@ public abstract class BaseStatusListFragment exten } } }); + list.setItemAnimator(new BetterItemAnimator()); updateToolbar(); } @@ -423,6 +430,73 @@ public abstract class BaseStatusListFragment exten .exec(accountID); } + public void onRevealSpoilerClick(TextStatusDisplayItem.Holder holder){ + Status status=holder.getItem().status; + revealSpoiler(status, holder.getItemID()); + } + + public void onRevealSpoilerClick(ImageStatusDisplayItem.Holder holder){ + Status status=holder.getItem().status; + revealSpoiler(status, holder.getItemID()); + } + + protected void revealSpoiler(Status status, String itemID){ + status.spoilerRevealed=true; + TextStatusDisplayItem.Holder text=findHolderOfType(itemID, TextStatusDisplayItem.Holder.class); + if(text!=null) + adapter.notifyItemChanged(text.getAbsoluteAdapterPosition()); + HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class); + if(header!=null) + header.rebind(); + for(ImageStatusDisplayItem.Holder photo:(List)findAllHoldersOfType(itemID, ImageStatusDisplayItem.Holder.class)){ + photo.setRevealed(true); + } + } + + public void onVisibilityIconClick(HeaderStatusDisplayItem.Holder holder){ + Status status=holder.getItem().status; + status.spoilerRevealed=!status.spoilerRevealed; + if(!TextUtils.isEmpty(status.spoilerText)){ + TextStatusDisplayItem.Holder text=findHolderOfType(holder.getItemID(), TextStatusDisplayItem.Holder.class); + if(text!=null){ + adapter.notifyItemChanged(text.getAbsoluteAdapterPosition()); + } + } + holder.rebind(); + for(ImageStatusDisplayItem.Holder photo:(List)findAllHoldersOfType(holder.getItemID(), ImageStatusDisplayItem.Holder.class)){ + photo.setRevealed(status.spoilerRevealed); + } + } + + @Nullable + protected I findItemOfType(String id, Class type){ + for(StatusDisplayItem item:displayItems){ + if(item.parentID.equals(id) && type.isInstance(item)) + return type.cast(item); + } + return null; + } + + @Nullable + protected > H findHolderOfType(String id, Class type){ + for(int i=0;i) holder).getItemID().equals(id) && type.isInstance(holder)) + return type.cast(holder); + } + return null; + } + + protected > List findAllHoldersOfType(String id, Class type){ + ArrayList holders=new ArrayList<>(); + for(int i=0;i) holder).getItemID().equals(id) && type.isInstance(holder)) + holders.add(type.cast(holder)); + } + return holders; + } + protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter> implements ImageLoaderRecyclerAdapter{ public DisplayItemsAdapter(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Status.java b/mastodon/src/main/java/org/joinmastodon/android/model/Status.java index 3c58fd91..7ea43436 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Status.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Status.java @@ -53,6 +53,8 @@ public class Status extends BaseModel implements DisplayItemsParent{ public boolean bookmarked; public boolean pinned; + public transient boolean spoilerRevealed; + @Override public void postprocess() throws ObjectValidationException{ super.postprocess(); @@ -73,6 +75,8 @@ public class Status extends BaseModel implements DisplayItemsParent{ card.postprocess(); if(reblog!=null) reblog.postprocess(); + + spoilerRevealed=!sensitive; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/BetterItemAnimator.java b/mastodon/src/main/java/org/joinmastodon/android/ui/BetterItemAnimator.java new file mode 100644 index 00000000..cbdf7d8a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/BetterItemAnimator.java @@ -0,0 +1,670 @@ +/* + * Copyright 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.joinmastodon.android.ui; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.TimeInterpolator; +import android.animation.ValueAnimator; +import android.view.View; +import android.view.ViewPropertyAnimator; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.SimpleItemAnimator; +import me.grishka.appkit.utils.CubicBezierInterpolator; + +import java.util.ArrayList; +import java.util.List; + +/** + * This differs from DefaultItemAnimator by running all animations at once without delays. + * + * @see RecyclerView#setItemAnimator(RecyclerView.ItemAnimator) + */ +public class BetterItemAnimator extends SimpleItemAnimator{ + private static final boolean DEBUG = false; + + private static TimeInterpolator sDefaultInterpolator; + + private ArrayList mPendingRemovals = new ArrayList<>(); + private ArrayList mPendingAdditions = new ArrayList<>(); + private ArrayList mPendingMoves = new ArrayList<>(); + private ArrayList mPendingChanges = new ArrayList<>(); + + ArrayList> mAdditionsList = new ArrayList<>(); + ArrayList> mMovesList = new ArrayList<>(); + ArrayList> mChangesList = new ArrayList<>(); + + ArrayList mAddAnimations = new ArrayList<>(); + ArrayList mMoveAnimations = new ArrayList<>(); + ArrayList mRemoveAnimations = new ArrayList<>(); + ArrayList mChangeAnimations = new ArrayList<>(); + + private static class MoveInfo { + public RecyclerView.ViewHolder holder; + public int fromX, fromY, toX, toY; + + MoveInfo(RecyclerView.ViewHolder holder, int fromX, int fromY, int toX, int toY) { + this.holder = holder; + this.fromX = fromX; + this.fromY = fromY; + this.toX = toX; + this.toY = toY; + } + } + + private static class ChangeInfo { + public RecyclerView.ViewHolder oldHolder, newHolder; + public int fromX, fromY, toX, toY; + private ChangeInfo(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder) { + this.oldHolder = oldHolder; + this.newHolder = newHolder; + } + + ChangeInfo(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder, + int fromX, int fromY, int toX, int toY) { + this(oldHolder, newHolder); + this.fromX = fromX; + this.fromY = fromY; + this.toX = toX; + this.toY = toY; + } + + @Override + public String toString() { + return "ChangeInfo{" + + "oldHolder=" + oldHolder + + ", newHolder=" + newHolder + + ", fromX=" + fromX + + ", fromY=" + fromY + + ", toX=" + toX + + ", toY=" + toY + + '}'; + } + } + + public BetterItemAnimator(){ + setAddDuration(250); + setRemoveDuration(250); + setChangeDuration(250); + setMoveDuration(250); + } + + @Override + public void runPendingAnimations() { + boolean removalsPending = !mPendingRemovals.isEmpty(); + boolean movesPending = !mPendingMoves.isEmpty(); + boolean changesPending = !mPendingChanges.isEmpty(); + boolean additionsPending = !mPendingAdditions.isEmpty(); + if (!removalsPending && !movesPending && !additionsPending && !changesPending) { + // nothing to animate + return; + } + // First, remove stuff + for (RecyclerView.ViewHolder holder : mPendingRemovals) { + animateRemoveImpl(holder); + } + mPendingRemovals.clear(); + // Next, move stuff + if (movesPending) { + final ArrayList moves = new ArrayList<>(); + moves.addAll(mPendingMoves); + mMovesList.add(moves); + mPendingMoves.clear(); + Runnable mover = new Runnable() { + @Override + public void run() { + for (MoveInfo moveInfo : moves) { + animateMoveImpl(moveInfo.holder, moveInfo.fromX, moveInfo.fromY, + moveInfo.toX, moveInfo.toY); + } + moves.clear(); + mMovesList.remove(moves); + } + }; + if (removalsPending) { + View view = moves.get(0).holder.itemView; + view.postOnAnimation(mover); + } else { + mover.run(); + } + } + // Next, change stuff, to run in parallel with move animations + if (changesPending) { + final ArrayList changes = new ArrayList<>(); + changes.addAll(mPendingChanges); + mChangesList.add(changes); + mPendingChanges.clear(); + Runnable changer = new Runnable() { + @Override + public void run() { + for (ChangeInfo change : changes) { + animateChangeImpl(change); + } + changes.clear(); + mChangesList.remove(changes); + } + }; + if (removalsPending) { + RecyclerView.ViewHolder holder = changes.get(0).oldHolder; + holder.itemView.postOnAnimation(changer); + } else { + changer.run(); + } + } + // Next, add stuff + if (additionsPending) { + final ArrayList additions = new ArrayList<>(); + additions.addAll(mPendingAdditions); + mAdditionsList.add(additions); + mPendingAdditions.clear(); + Runnable adder = new Runnable() { + @Override + public void run() { + for (RecyclerView.ViewHolder holder : additions) { + animateAddImpl(holder); + } + additions.clear(); + mAdditionsList.remove(additions); + } + }; + if (removalsPending || movesPending || changesPending) { + View view = additions.get(0).itemView; + view.postOnAnimation(adder); + } else { + adder.run(); + } + } + } + + @Override + public boolean animateRemove(final RecyclerView.ViewHolder holder) { + resetAnimation(holder); + mPendingRemovals.add(holder); + return true; + } + + private void animateRemoveImpl(final RecyclerView.ViewHolder holder) { + final View view = holder.itemView; + final ViewPropertyAnimator animation = view.animate(); + mRemoveAnimations.add(holder); + animation.setDuration(getRemoveDuration()).alpha(0).setListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animator) { + dispatchRemoveStarting(holder); + } + + @Override + public void onAnimationEnd(Animator animator) { + animation.setListener(null); + view.setAlpha(1); + dispatchRemoveFinished(holder); + mRemoveAnimations.remove(holder); + dispatchFinishedWhenDone(); + } + }).start(); + } + + @Override + public boolean animateAdd(final RecyclerView.ViewHolder holder) { + resetAnimation(holder); + holder.itemView.setAlpha(0); + mPendingAdditions.add(holder); + return true; + } + + void animateAddImpl(final RecyclerView.ViewHolder holder) { + final View view = holder.itemView; + final ViewPropertyAnimator animation = view.animate(); + mAddAnimations.add(holder); + animation.alpha(1).setDuration(getAddDuration()) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animator) { + dispatchAddStarting(holder); + } + + @Override + public void onAnimationCancel(Animator animator) { + view.setAlpha(1); + } + + @Override + public void onAnimationEnd(Animator animator) { + animation.setListener(null); + dispatchAddFinished(holder); + mAddAnimations.remove(holder); + dispatchFinishedWhenDone(); + } + }).start(); + } + + @Override + public boolean animateMove(final RecyclerView.ViewHolder holder, int fromX, int fromY, + int toX, int toY) { + final View view = holder.itemView; + fromX += (int) holder.itemView.getTranslationX(); + fromY += (int) holder.itemView.getTranslationY(); + resetAnimation(holder); + int deltaX = toX - fromX; + int deltaY = toY - fromY; + if (deltaX == 0 && deltaY == 0) { + dispatchMoveFinished(holder); + return false; + } + if (deltaX != 0) { + view.setTranslationX(-deltaX); + } + if (deltaY != 0) { + view.setTranslationY(-deltaY); + } + mPendingMoves.add(new MoveInfo(holder, fromX, fromY, toX, toY)); + return true; + } + + void animateMoveImpl(final RecyclerView.ViewHolder holder, int fromX, int fromY, int toX, int toY) { + final View view = holder.itemView; + final int deltaX = toX - fromX; + final int deltaY = toY - fromY; + if (deltaX != 0) { + view.animate().translationX(0); + } + if (deltaY != 0) { + view.animate().translationY(0); + } + // TODO: make EndActions end listeners instead, since end actions aren't called when + // vpas are canceled (and can't end them. why?) + // need listener functionality in VPACompat for this. Ick. + final ViewPropertyAnimator animation = view.animate(); + mMoveAnimations.add(holder); + animation.setDuration(getMoveDuration()).setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animator) { + dispatchMoveStarting(holder); + } + + @Override + public void onAnimationCancel(Animator animator) { + if (deltaX != 0) { + view.setTranslationX(0); + } + if (deltaY != 0) { + view.setTranslationY(0); + } + } + + @Override + public void onAnimationEnd(Animator animator) { + animation.setListener(null); + dispatchMoveFinished(holder); + mMoveAnimations.remove(holder); + dispatchFinishedWhenDone(); + } + }).start(); + } + + @Override + public boolean animateChange(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder, + int fromX, int fromY, int toX, int toY) { + if (oldHolder == newHolder) { + // Don't know how to run change animations when the same view holder is re-used. + // run a move animation to handle position changes. + return animateMove(oldHolder, fromX, fromY, toX, toY); + } + final float prevTranslationX = oldHolder.itemView.getTranslationX(); + final float prevTranslationY = oldHolder.itemView.getTranslationY(); + final float prevAlpha = oldHolder.itemView.getAlpha(); + resetAnimation(oldHolder); + int deltaX = (int) (toX - fromX - prevTranslationX); + int deltaY = (int) (toY - fromY - prevTranslationY); + // recover prev translation state after ending animation + oldHolder.itemView.setTranslationX(prevTranslationX); + oldHolder.itemView.setTranslationY(prevTranslationY); + oldHolder.itemView.setAlpha(prevAlpha); + if (newHolder != null) { + // carry over translation values + resetAnimation(newHolder); + newHolder.itemView.setTranslationX(-deltaX); + newHolder.itemView.setTranslationY(-deltaY); + newHolder.itemView.setAlpha(0); + } + mPendingChanges.add(new ChangeInfo(oldHolder, newHolder, fromX, fromY, toX, toY)); + return true; + } + + void animateChangeImpl(final ChangeInfo changeInfo) { + final RecyclerView.ViewHolder holder = changeInfo.oldHolder; + final View view = holder == null ? null : holder.itemView; + final RecyclerView.ViewHolder newHolder = changeInfo.newHolder; + final View newView = newHolder != null ? newHolder.itemView : null; + if (view != null) { + final ViewPropertyAnimator oldViewAnim = view.animate().setDuration( + getChangeDuration()); + mChangeAnimations.add(changeInfo.oldHolder); + oldViewAnim.translationX(changeInfo.toX - changeInfo.fromX); + oldViewAnim.translationY(changeInfo.toY - changeInfo.fromY); + oldViewAnim.alpha(0).setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animator) { + dispatchChangeStarting(changeInfo.oldHolder, true); + } + + @Override + public void onAnimationEnd(Animator animator) { + oldViewAnim.setListener(null); + view.setAlpha(1); + view.setTranslationX(0); + view.setTranslationY(0); + dispatchChangeFinished(changeInfo.oldHolder, true); + mChangeAnimations.remove(changeInfo.oldHolder); + dispatchFinishedWhenDone(); + } + }).start(); + } + if (newView != null) { + final ViewPropertyAnimator newViewAnimation = newView.animate(); + mChangeAnimations.add(changeInfo.newHolder); + newViewAnimation.translationX(0).translationY(0).setDuration(getChangeDuration()) + .alpha(1).setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animator) { + dispatchChangeStarting(changeInfo.newHolder, false); + } + @Override + public void onAnimationEnd(Animator animator) { + newViewAnimation.setListener(null); + newView.setAlpha(1); + newView.setTranslationX(0); + newView.setTranslationY(0); + dispatchChangeFinished(changeInfo.newHolder, false); + mChangeAnimations.remove(changeInfo.newHolder); + dispatchFinishedWhenDone(); + } + }).start(); + } + } + + private void endChangeAnimation(List infoList, RecyclerView.ViewHolder item) { + for (int i = infoList.size() - 1; i >= 0; i--) { + ChangeInfo changeInfo = infoList.get(i); + if (endChangeAnimationIfNecessary(changeInfo, item)) { + if (changeInfo.oldHolder == null && changeInfo.newHolder == null) { + infoList.remove(changeInfo); + } + } + } + } + + private void endChangeAnimationIfNecessary(ChangeInfo changeInfo) { + if (changeInfo.oldHolder != null) { + endChangeAnimationIfNecessary(changeInfo, changeInfo.oldHolder); + } + if (changeInfo.newHolder != null) { + endChangeAnimationIfNecessary(changeInfo, changeInfo.newHolder); + } + } + private boolean endChangeAnimationIfNecessary(ChangeInfo changeInfo, RecyclerView.ViewHolder item) { + boolean oldItem = false; + if (changeInfo.newHolder == item) { + changeInfo.newHolder = null; + } else if (changeInfo.oldHolder == item) { + changeInfo.oldHolder = null; + oldItem = true; + } else { + return false; + } + item.itemView.setAlpha(1); + item.itemView.setTranslationX(0); + item.itemView.setTranslationY(0); + dispatchChangeFinished(item, oldItem); + return true; + } + + @Override + public void endAnimation(RecyclerView.ViewHolder item) { + final View view = item.itemView; + // this will trigger end callback which should set properties to their target values. + view.animate().cancel(); + // TODO if some other animations are chained to end, how do we cancel them as well? + for (int i = mPendingMoves.size() - 1; i >= 0; i--) { + MoveInfo moveInfo = mPendingMoves.get(i); + if (moveInfo.holder == item) { + view.setTranslationY(0); + view.setTranslationX(0); + dispatchMoveFinished(item); + mPendingMoves.remove(i); + } + } + endChangeAnimation(mPendingChanges, item); + if (mPendingRemovals.remove(item)) { + view.setAlpha(1); + dispatchRemoveFinished(item); + } + if (mPendingAdditions.remove(item)) { + view.setAlpha(1); + dispatchAddFinished(item); + } + + for (int i = mChangesList.size() - 1; i >= 0; i--) { + ArrayList changes = mChangesList.get(i); + endChangeAnimation(changes, item); + if (changes.isEmpty()) { + mChangesList.remove(i); + } + } + for (int i = mMovesList.size() - 1; i >= 0; i--) { + ArrayList moves = mMovesList.get(i); + for (int j = moves.size() - 1; j >= 0; j--) { + MoveInfo moveInfo = moves.get(j); + if (moveInfo.holder == item) { + view.setTranslationY(0); + view.setTranslationX(0); + dispatchMoveFinished(item); + moves.remove(j); + if (moves.isEmpty()) { + mMovesList.remove(i); + } + break; + } + } + } + for (int i = mAdditionsList.size() - 1; i >= 0; i--) { + ArrayList additions = mAdditionsList.get(i); + if (additions.remove(item)) { + view.setAlpha(1); + dispatchAddFinished(item); + if (additions.isEmpty()) { + mAdditionsList.remove(i); + } + } + } + + // animations should be ended by the cancel above. + //noinspection PointlessBooleanExpression,ConstantConditions + if (mRemoveAnimations.remove(item) && DEBUG) { + throw new IllegalStateException("after animation is cancelled, item should not be in " + + "mRemoveAnimations list"); + } + + //noinspection PointlessBooleanExpression,ConstantConditions + if (mAddAnimations.remove(item) && DEBUG) { + throw new IllegalStateException("after animation is cancelled, item should not be in " + + "mAddAnimations list"); + } + + //noinspection PointlessBooleanExpression,ConstantConditions + if (mChangeAnimations.remove(item) && DEBUG) { + throw new IllegalStateException("after animation is cancelled, item should not be in " + + "mChangeAnimations list"); + } + + //noinspection PointlessBooleanExpression,ConstantConditions + if (mMoveAnimations.remove(item) && DEBUG) { + throw new IllegalStateException("after animation is cancelled, item should not be in " + + "mMoveAnimations list"); + } + dispatchFinishedWhenDone(); + } + + private void resetAnimation(RecyclerView.ViewHolder holder) { + if (sDefaultInterpolator == null) { + sDefaultInterpolator =CubicBezierInterpolator.DEFAULT; + } + holder.itemView.animate().setInterpolator(sDefaultInterpolator); + endAnimation(holder); + } + + @Override + public boolean isRunning() { + return (!mPendingAdditions.isEmpty() + || !mPendingChanges.isEmpty() + || !mPendingMoves.isEmpty() + || !mPendingRemovals.isEmpty() + || !mMoveAnimations.isEmpty() + || !mRemoveAnimations.isEmpty() + || !mAddAnimations.isEmpty() + || !mChangeAnimations.isEmpty() + || !mMovesList.isEmpty() + || !mAdditionsList.isEmpty() + || !mChangesList.isEmpty()); + } + + /** + * Check the state of currently pending and running animations. If there are none + * pending/running, call {@link #dispatchAnimationsFinished()} to notify any + * listeners. + */ + void dispatchFinishedWhenDone() { + if (!isRunning()) { + dispatchAnimationsFinished(); + } + } + + @Override + public void endAnimations() { + int count = mPendingMoves.size(); + for (int i = count - 1; i >= 0; i--) { + MoveInfo item = mPendingMoves.get(i); + View view = item.holder.itemView; + view.setTranslationY(0); + view.setTranslationX(0); + dispatchMoveFinished(item.holder); + mPendingMoves.remove(i); + } + count = mPendingRemovals.size(); + for (int i = count - 1; i >= 0; i--) { + RecyclerView.ViewHolder item = mPendingRemovals.get(i); + dispatchRemoveFinished(item); + mPendingRemovals.remove(i); + } + count = mPendingAdditions.size(); + for (int i = count - 1; i >= 0; i--) { + RecyclerView.ViewHolder item = mPendingAdditions.get(i); + item.itemView.setAlpha(1); + dispatchAddFinished(item); + mPendingAdditions.remove(i); + } + count = mPendingChanges.size(); + for (int i = count - 1; i >= 0; i--) { + endChangeAnimationIfNecessary(mPendingChanges.get(i)); + } + mPendingChanges.clear(); + if (!isRunning()) { + return; + } + + int listCount = mMovesList.size(); + for (int i = listCount - 1; i >= 0; i--) { + ArrayList moves = mMovesList.get(i); + count = moves.size(); + for (int j = count - 1; j >= 0; j--) { + MoveInfo moveInfo = moves.get(j); + RecyclerView.ViewHolder item = moveInfo.holder; + View view = item.itemView; + view.setTranslationY(0); + view.setTranslationX(0); + dispatchMoveFinished(moveInfo.holder); + moves.remove(j); + if (moves.isEmpty()) { + mMovesList.remove(moves); + } + } + } + listCount = mAdditionsList.size(); + for (int i = listCount - 1; i >= 0; i--) { + ArrayList additions = mAdditionsList.get(i); + count = additions.size(); + for (int j = count - 1; j >= 0; j--) { + RecyclerView.ViewHolder item = additions.get(j); + View view = item.itemView; + view.setAlpha(1); + dispatchAddFinished(item); + additions.remove(j); + if (additions.isEmpty()) { + mAdditionsList.remove(additions); + } + } + } + listCount = mChangesList.size(); + for (int i = listCount - 1; i >= 0; i--) { + ArrayList changes = mChangesList.get(i); + count = changes.size(); + for (int j = count - 1; j >= 0; j--) { + endChangeAnimationIfNecessary(changes.get(j)); + if (changes.isEmpty()) { + mChangesList.remove(changes); + } + } + } + + cancelAll(mRemoveAnimations); + cancelAll(mMoveAnimations); + cancelAll(mAddAnimations); + cancelAll(mChangeAnimations); + + dispatchAnimationsFinished(); + } + + void cancelAll(List viewHolders) { + for (int i = viewHolders.size() - 1; i >= 0; i--) { + viewHolders.get(i).itemView.animate().cancel(); + } + } + + /** + * {@inheritDoc} + *

+ * If the payload list is not empty, DefaultItemAnimator returns true. + * When this is the case: + *

    + *
  • If you override {@link #animateChange(RecyclerView.ViewHolder, RecyclerView.ViewHolder, int, int, int, int)}, both + * ViewHolder arguments will be the same instance. + *
  • + *
  • + * If you are not overriding {@link #animateChange(RecyclerView.ViewHolder, RecyclerView.ViewHolder, int, int, int, int)}, + * then DefaultItemAnimator will call {@link #animateMove(RecyclerView.ViewHolder, int, int, int, int)} and + * run a move animation instead. + *
  • + *
+ */ + @Override + public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, + @NonNull List payloads) { + return !payloads.isEmpty() || super.canReuseUpdatedViewHolder(viewHolder, payloads); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java index 0bae85f1..3d869509 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java @@ -7,6 +7,7 @@ import android.graphics.drawable.Animatable; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.text.SpannableStringBuilder; +import android.text.TextUtils; import android.view.View; import android.view.ViewGroup; import android.view.ViewOutlineProvider; @@ -17,6 +18,8 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.fragments.BaseStatusListFragment; import org.joinmastodon.android.fragments.ProfileFragment; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Attachment; +import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.UiUtils; import org.parceler.Parcels; @@ -33,21 +36,31 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ private Account user; private Instant createdAt; private ImageLoaderRequest avaRequest; - private Fragment parentFragment; private String accountID; private CustomEmojiHelper emojiHelper=new CustomEmojiHelper(); private SpannableStringBuilder parsedName; + public final Status status; + private boolean hasVisibilityToggle; - public HeaderStatusDisplayItem(String parentID, Account user, Instant createdAt, BaseStatusListFragment parentFragment, String accountID){ + public HeaderStatusDisplayItem(String parentID, Account user, Instant createdAt, BaseStatusListFragment parentFragment, String accountID, Status status){ super(parentID, parentFragment); this.user=user; this.createdAt=createdAt; avaRequest=new UrlImageLoaderRequest(user.avatar); - this.parentFragment=parentFragment; this.accountID=accountID; parsedName=new SpannableStringBuilder(user.displayName); + this.status=status; HtmlParser.parseCustomEmoji(parsedName, user.emojis); emojiHelper.setText(parsedName); + hasVisibilityToggle=status.sensitive || !TextUtils.isEmpty(status.spoilerText); + if(!hasVisibilityToggle && !status.mediaAttachments.isEmpty()){ + for(Attachment att:status.mediaAttachments){ + if(att.type!=Attachment.Type.AUDIO){ + hasVisibilityToggle=true; + break; + } + } + } } @Override @@ -70,7 +83,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ public static class Holder extends StatusDisplayItem.Holder implements ImageLoaderViewHolder{ private final TextView name, username, timestamp; - private final ImageView avatar, more; + private final ImageView avatar, more, visibility; private static final ViewOutlineProvider roundCornersOutline=new ViewOutlineProvider(){ @Override @@ -86,10 +99,12 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ timestamp=findViewById(R.id.timestamp); avatar=findViewById(R.id.avatar); more=findViewById(R.id.more); + visibility=findViewById(R.id.visibility); avatar.setOnClickListener(this::onAvaClick); avatar.setOutlineProvider(roundCornersOutline); avatar.setClipToOutline(true); more.setOnClickListener(this::onMoreClick); + visibility.setOnClickListener(v->item.parentFragment.onVisibilityIconClick(this)); } @Override @@ -97,6 +112,10 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ name.setText(item.parsedName); username.setText('@'+item.user.acct); timestamp.setText(UiUtils.formatRelativeTimestamp(itemView.getContext(), item.createdAt)); + visibility.setVisibility(item.hasVisibilityToggle ? View.VISIBLE : View.GONE); + if(item.hasVisibilityToggle){ + visibility.setImageResource(item.status.spoilerRevealed ? R.drawable.ic_visibility_off : R.drawable.ic_visibility); + } } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ImageStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ImageStatusDisplayItem.java index 1d431296..23e1de94 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ImageStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ImageStatusDisplayItem.java @@ -11,6 +11,7 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.fragments.BaseStatusListFragment; import org.joinmastodon.android.model.Attachment; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.drawables.BlurhashCrossfadeDrawable; import org.joinmastodon.android.ui.photoviewer.PhotoViewerHost; import androidx.annotation.LayoutRes; @@ -23,13 +24,11 @@ public abstract class ImageStatusDisplayItem extends StatusDisplayItem{ public final int totalPhotos; protected Attachment attachment; protected ImageLoaderRequest request; - protected Fragment parentFragment; - protected Status status; + public final Status status; public ImageStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Attachment photo, Status status, int index, int totalPhotos){ super(parentID, parentFragment); this.attachment=photo; - this.parentFragment=parentFragment; this.status=status; this.index=index; this.totalPhotos=totalPhotos; @@ -47,6 +46,8 @@ public abstract class ImageStatusDisplayItem extends StatusDisplayItem{ public static abstract class Holder extends StatusDisplayItem.Holder implements ImageLoaderViewHolder{ public final ImageView photo; + private BlurhashCrossfadeDrawable crossfadeDrawable=new BlurhashCrossfadeDrawable(); + private boolean didClear; public Holder(Activity activity, @LayoutRes int layout, ViewGroup parent){ super(activity, layout, parent); @@ -56,24 +57,38 @@ public abstract class ImageStatusDisplayItem extends StatusDisplayItem{ @Override public void onBind(ImageStatusDisplayItem item){ - + crossfadeDrawable.setSize(item.attachment.getWidth(), item.attachment.getHeight()); + crossfadeDrawable.setBlurhashDrawable(item.attachment.blurhashPlaceholder); + crossfadeDrawable.setCrossfadeAlpha(item.status.spoilerRevealed ? 0f : 1f); + photo.setImageDrawable(null); + photo.setImageDrawable(crossfadeDrawable); + didClear=false; } @Override public void setImage(int index, Drawable drawable){ - photo.setImageDrawable(drawable); + crossfadeDrawable.setImageDrawable(drawable); + if(didClear && item.status.spoilerRevealed) + crossfadeDrawable.animateAlpha(0f); } @Override public void clearImage(int index){ - photo.setImageDrawable(item.attachment.blurhashPlaceholder); + crossfadeDrawable.setCrossfadeAlpha(1f); + didClear=true; } private void onViewClick(View v){ - if(item.parentFragment instanceof PhotoViewerHost){ + if(!item.status.spoilerRevealed){ + item.parentFragment.onRevealSpoilerClick(this); + }else if(item.parentFragment instanceof PhotoViewerHost){ Status contentStatus=item.status.reblog!=null ? item.status.reblog : item.status; ((PhotoViewerHost) item.parentFragment).openPhotoViewer(item.parentID, item.status, contentStatus.mediaAttachments.indexOf(item.attachment)); } } + + public void setRevealed(boolean revealed){ + crossfadeDrawable.animateAlpha(revealed ? 0f : 1f); + } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java index 66e4f629..aac26ad0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java @@ -68,9 +68,9 @@ public abstract class StatusDisplayItem{ Account account=Objects.requireNonNull(knownAccounts.get(status.inReplyToAccountId)); items.add(new ReblogOrReplyLineStatusDisplayItem(parentID, fragment, fragment.getString(R.string.in_reply_to, account.displayName), account.emojis, R.drawable.ic_fluent_arrow_reply_20_filled)); } - items.add(new HeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID)); + items.add(new HeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent)); if(!TextUtils.isEmpty(statusForContent.content)) - items.add(new TextStatusDisplayItem(parentID, HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, accountID), fragment)); + items.add(new TextStatusDisplayItem(parentID, HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, accountID), fragment, statusForContent)); int photoIndex=0; int totalPhotos=0; for(Attachment attachment:statusForContent.mediaAttachments){ @@ -80,13 +80,13 @@ public abstract class StatusDisplayItem{ } for(Attachment attachment:statusForContent.mediaAttachments){ if(attachment.type==Attachment.Type.IMAGE){ - items.add(new PhotoStatusDisplayItem(parentID, status, attachment, fragment, photoIndex, totalPhotos)); + items.add(new PhotoStatusDisplayItem(parentID, statusForContent, attachment, fragment, photoIndex, totalPhotos)); photoIndex++; }else if(attachment.type==Attachment.Type.GIFV){ - items.add(new GifVStatusDisplayItem(parentID, status, attachment, fragment, photoIndex, totalPhotos)); + items.add(new GifVStatusDisplayItem(parentID, statusForContent, attachment, fragment, photoIndex, totalPhotos)); photoIndex++; }else if(attachment.type==Attachment.Type.VIDEO){ - items.add(new VideoStatusDisplayItem(parentID, status, attachment, fragment, photoIndex, totalPhotos)); + items.add(new VideoStatusDisplayItem(parentID, statusForContent, attachment, fragment, photoIndex, totalPhotos)); photoIndex++; } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java index 4c1d24cc..239a7f61 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/TextStatusDisplayItem.java @@ -3,10 +3,14 @@ package org.joinmastodon.android.ui.displayitems; import android.app.Activity; import android.graphics.drawable.Animatable; import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.view.View; import android.view.ViewGroup; +import android.widget.TextView; import org.joinmastodon.android.R; import org.joinmastodon.android.fragments.BaseStatusListFragment; +import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.views.LinkedTextView; import me.grishka.appkit.imageloader.ImageLoaderViewHolder; @@ -16,9 +20,12 @@ import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; public class TextStatusDisplayItem extends StatusDisplayItem{ private CharSequence text; private CustomEmojiHelper emojiHelper=new CustomEmojiHelper(); - public TextStatusDisplayItem(String parentID, CharSequence text, BaseStatusListFragment parentFragment){ + public final Status status; + + public TextStatusDisplayItem(String parentID, CharSequence text, BaseStatusListFragment parentFragment, Status status){ super(parentID, parentFragment); this.text=text; + this.status=status; emojiHelper.setText(text); } @@ -39,16 +46,37 @@ public class TextStatusDisplayItem extends StatusDisplayItem{ public static class Holder extends StatusDisplayItem.Holder implements ImageLoaderViewHolder{ private final LinkedTextView text; + private final TextView spoilerTitle; + private final View spoilerOverlay; public Holder(Activity activity, ViewGroup parent){ super(activity, R.layout.display_item_text, parent); text=findViewById(R.id.text); + spoilerTitle=findViewById(R.id.spoiler_title); + spoilerOverlay=findViewById(R.id.spoiler_overlay); + itemView.setOnClickListener(v->item.parentFragment.onRevealSpoilerClick(this)); } @Override public void onBind(TextStatusDisplayItem item){ text.setText(item.text); text.setInvalidateOnEveryFrame(false); + if(!TextUtils.isEmpty(item.status.spoilerText)){ + spoilerTitle.setText(item.status.spoilerText); + if(item.status.spoilerRevealed){ + spoilerOverlay.setVisibility(View.GONE); + text.setVisibility(View.VISIBLE); + itemView.setClickable(false); + }else{ + spoilerOverlay.setVisibility(View.VISIBLE); + text.setVisibility(View.INVISIBLE); + itemView.setClickable(true); + } + }else{ + spoilerOverlay.setVisibility(View.GONE); + text.setVisibility(View.VISIBLE); + itemView.setClickable(false); + } } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/BlurhashCrossfadeDrawable.java b/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/BlurhashCrossfadeDrawable.java new file mode 100644 index 00000000..c909fcb8 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/BlurhashCrossfadeDrawable.java @@ -0,0 +1,130 @@ +package org.joinmastodon.android.ui.drawables; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.PixelFormat; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.util.FloatProperty; +import android.util.Property; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import me.grishka.appkit.utils.CubicBezierInterpolator; + +public class BlurhashCrossfadeDrawable extends Drawable{ + + private int width, height; + private Drawable blurhashDrawable, imageDrawable; + private float blurhashAlpha=1f; + private ObjectAnimator currentAnim; + + private static Property BLURHASH_ALPHA; + + static{ + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){ + BLURHASH_ALPHA=new FloatProperty<>(""){ + @Override + public Float get(BlurhashCrossfadeDrawable object){ + return object.blurhashAlpha; + } + + @Override + public void setValue(BlurhashCrossfadeDrawable object, float value){ + object.blurhashAlpha=value; + object.invalidateSelf(); + } + }; + }else{ + BLURHASH_ALPHA=new Property<>(Float.class, ""){ + @Override + public Float get(BlurhashCrossfadeDrawable object){ + return object.blurhashAlpha; + } + + @Override + public void set(BlurhashCrossfadeDrawable object, Float value){ + object.blurhashAlpha=value; + object.invalidateSelf(); + } + }; + } + } + + public void setSize(int w, int h){ + width=w; + height=h; + } + + public void setBlurhashDrawable(Drawable blurhashDrawable){ + this.blurhashDrawable=blurhashDrawable; + invalidateSelf(); + } + + public void setImageDrawable(Drawable imageDrawable){ + this.imageDrawable=imageDrawable; + invalidateSelf(); + } + + @Override + public void draw(@NonNull Canvas canvas){ + if(imageDrawable!=null && blurhashAlpha<1f){ + imageDrawable.setBounds(getBounds()); + imageDrawable.draw(canvas); + } + if(blurhashDrawable!=null && blurhashAlpha>0f){ + blurhashDrawable.setBounds(getBounds()); + blurhashDrawable.setAlpha(Math.round(255*blurhashAlpha)); + blurhashDrawable.draw(canvas); + } + } + + @Override + public void setAlpha(int alpha){ + + } + + @Override + public void setColorFilter(@Nullable ColorFilter colorFilter){ + + } + + @Override + public int getOpacity(){ + return PixelFormat.OPAQUE; + } + + @Override + public int getIntrinsicWidth(){ + return width; + } + + @Override + public int getIntrinsicHeight(){ + return height; + } + + public void animateAlpha(float target){ + if(currentAnim!=null) + currentAnim.cancel(); + ObjectAnimator anim=ObjectAnimator.ofFloat(this, BLURHASH_ALPHA, target); + anim.setDuration(250); + anim.setInterpolator(CubicBezierInterpolator.DEFAULT); + anim.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + currentAnim=null; + } + }); + anim.start(); + currentAnim=anim; + } + + public void setCrossfadeAlpha(float alpha){ + blurhashAlpha=alpha; + invalidateSelf(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/BlurHashDrawable.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/BlurHashDrawable.java index 060be352..0763c184 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/BlurHashDrawable.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/BlurHashDrawable.java @@ -13,7 +13,7 @@ import androidx.annotation.Nullable; public class BlurHashDrawable extends Drawable{ private final Bitmap bitmap; private final int width, height; - private static final Paint PAINT=new Paint(Paint.FILTER_BITMAP_FLAG); + private final Paint paint=new Paint(Paint.FILTER_BITMAP_FLAG); public BlurHashDrawable(Bitmap bitmap, int width, int height){ this.bitmap=bitmap; @@ -23,12 +23,12 @@ public class BlurHashDrawable extends Drawable{ @Override public void draw(@NonNull Canvas canvas){ - canvas.drawBitmap(bitmap, null, getBounds(), PAINT); + canvas.drawBitmap(bitmap, null, getBounds(), paint); } @Override public void setAlpha(int alpha){ - + paint.setAlpha(alpha); } @Override diff --git a/mastodon/src/main/res/drawable/ic_visibility.xml b/mastodon/src/main/res/drawable/ic_visibility.xml new file mode 100644 index 00000000..7a717bb6 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_visibility.xml @@ -0,0 +1,5 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_visibility_off.xml b/mastodon/src/main/res/drawable/ic_visibility_off.xml new file mode 100644 index 00000000..6c205741 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_visibility_off.xml @@ -0,0 +1,5 @@ + + + diff --git a/mastodon/src/main/res/layout/display_item_header.xml b/mastodon/src/main/res/layout/display_item_header.xml index 075061d5..ff67f352 100644 --- a/mastodon/src/main/res/layout/display_item_header.xml +++ b/mastodon/src/main/res/layout/display_item_header.xml @@ -17,6 +17,17 @@ android:scaleType="center" android:src="@drawable/ic_post_more" /> + + diff --git a/mastodon/src/main/res/layout/display_item_text.xml b/mastodon/src/main/res/layout/display_item_text.xml index d5b95a54..294f254f 100644 --- a/mastodon/src/main/res/layout/display_item_text.xml +++ b/mastodon/src/main/res/layout/display_item_text.xml @@ -1,5 +1,6 @@ + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/values/strings.xml b/mastodon/src/main/res/values/strings.xml index b306ffaf..5d1d7f3b 100644 --- a/mastodon/src/main/res/values/strings.xml +++ b/mastodon/src/main/res/values/strings.xml @@ -123,4 +123,5 @@ Muted Blocked Vote + Tap to reveal \ No newline at end of file