Fix rendering of links and mentions covered by spoilers.

This commit is contained in:
Cody Henthorne
2023-03-24 14:44:21 -04:00
parent 168e37c3fc
commit 7eb00e41a2
15 changed files with 154 additions and 50 deletions

View File

@@ -363,7 +363,7 @@ public class ComposeText extends EmojiEditText {
} else if (item.getItemId() == R.id.edittext_monospace) {
style = MessageStyler.monoStyle();
} else if (item.getItemId() == R.id.edittext_spoiler) {
style = MessageStyler.spoilerStyle(start, charSequence.length(), text);
style = MessageStyler.spoilerStyle(MessageStyler.COMPOSE_ID, start, charSequence.length(), text);
}
if (style != null) {

View File

@@ -5,6 +5,7 @@ import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.os.Build;
import android.text.Spannable;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
@@ -27,8 +28,10 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.quotes.QuoteViewColorTheme;
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
import org.thoughtcrime.securesms.conversation.MessageStyler;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
@@ -82,7 +85,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
private ViewGroup mainView;
private ViewGroup footerView;
private TextView authorView;
private TextView bodyView;
private EmojiTextView bodyView;
private View quoteBarView;
private ShapeableImageView thumbnailView;
private View attachmentVideoOverlayView;
@@ -163,6 +166,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
setMessageType(messageType);
bodyView.enableSpoilerFiltering();
dismissView.setOnClickListener(view -> setVisibility(GONE));
}

View File

@@ -7,6 +7,7 @@ import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.Annotation;
import android.text.Layout;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextDirectionHeuristic;
@@ -26,7 +27,6 @@ import androidx.core.content.ContextCompat;
import androidx.core.view.ViewKt;
import androidx.core.widget.TextViewCompat;
import org.jetbrains.annotations.NotNull;
import org.signal.core.util.StringUtil;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
@@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
import org.thoughtcrime.securesms.components.spoiler.SpoilerRendererDelegate;
import org.thoughtcrime.securesms.emoji.JumboEmoji;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable;
import org.thoughtcrime.securesms.util.Util;
import java.util.Arrays;
@@ -67,9 +68,11 @@ public class EmojiTextView extends AppCompatTextView {
private TextDirectionHeuristic textDirection;
private boolean isJumbomoji;
private boolean forceJumboEmoji;
private boolean isInOnDraw;
private MentionRendererDelegate mentionRendererDelegate;
private final SpoilerRendererDelegate spoilerRendererDelegate;
private MentionRendererDelegate mentionRendererDelegate;
private final SpoilerRendererDelegate spoilerRendererDelegate;
private SpoilerFilteringSpannableFactory spoilerFilteringSpannableFactory;
public EmojiTextView(Context context) {
this(context, null);
@@ -105,8 +108,14 @@ public class EmojiTextView extends AppCompatTextView {
setEmojiCompatEnabled(useSystemEmoji());
}
public void enableSpoilerFiltering() {
spoilerFilteringSpannableFactory = new SpoilerFilteringSpannableFactory();
setSpannableFactory(spoilerFilteringSpannableFactory);
}
@Override
protected void onDraw(Canvas canvas) {
isInOnDraw = true;
if (getText() instanceof Spanned && getLayout() != null) {
int checkpoint = canvas.save();
canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
@@ -120,6 +129,7 @@ public class EmojiTextView extends AppCompatTextView {
}
}
super.onDraw(canvas);
isInOnDraw = false;
}
@Override
@@ -151,13 +161,18 @@ public class EmojiTextView extends AppCompatTextView {
useSystemEmoji = useSystemEmoji();
previousTransformationMethod = getTransformationMethod();
Spannable textToSet;
if (useSystemEmoji || candidates == null || candidates.size() == 0) {
super.setText(new SpannableStringBuilder(Optional.ofNullable(text).orElse("")), BufferType.SPANNABLE);
textToSet = new SpannableStringBuilder(Optional.ofNullable(text).orElse(""));
} else {
CharSequence emojified = EmojiProvider.emojify(candidates, text, this, isJumbomoji || forceJumboEmoji);
super.setText(new SpannableStringBuilder(emojified), BufferType.SPANNABLE);
textToSet = new SpannableStringBuilder(EmojiProvider.emojify(candidates, text, this, isJumbomoji || forceJumboEmoji));
}
if (spoilerFilteringSpannableFactory != null) {
textToSet = spoilerFilteringSpannableFactory.wrap(textToSet);
}
super.setText(textToSet, BufferType.SPANNABLE);
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
// We ellipsize them ourselves by manually truncating the appropriate section.
if (getText() != null && getText().length() > 0 && isEllipsizedAtEnd()) {
@@ -410,4 +425,15 @@ public class EmojiTextView extends AppCompatTextView {
mentionRendererDelegate.setTint(mentionBackgroundTint);
}
}
private class SpoilerFilteringSpannableFactory extends Spannable.Factory {
@Override
public @NonNull Spannable newSpannable(CharSequence source) {
return wrap(super.newSpannable(source));
}
@NonNull SpoilerFilteringSpannable wrap(Spannable source) {
return new SpoilerFilteringSpannable(source, () -> isInOnDraw);
}
}
}

View File

@@ -22,27 +22,23 @@ object SpoilerAnnotation {
}
@JvmStatic
fun isSpoilerAnnotation(annotation: Annotation): Boolean {
return SPOILER_ANNOTATION == annotation.key
fun isSpoilerAnnotation(annotation: Any): Boolean {
return SPOILER_ANNOTATION == (annotation as? Annotation)?.key
}
@JvmStatic
fun getSpoilerAnnotations(spanned: Spanned): List<Annotation> {
val spoilerAnnotations: Map<Pair<Int, Int>, Annotation> = spanned.getSpans(0, spanned.length, Annotation::class.java)
fun getSpoilerAndClickAnnotations(spanned: Spanned, start: Int = 0, end: Int = spanned.length): Map<Annotation, SpoilerClickableSpan?> {
val spoilerAnnotations: Map<Pair<Int, Int>, Annotation> = spanned.getSpans(start, end, Annotation::class.java)
.filter { isSpoilerAnnotation(it) }
.associateBy { (spanned.getSpanStart(it) to spanned.getSpanEnd(it)) }
val spoilerClickSpans: Map<Pair<Int, Int>, SpoilerClickableSpan> = spanned.getSpans(0, spanned.length, SpoilerClickableSpan::class.java)
val spoilerClickSpans: Map<Pair<Int, Int>, SpoilerClickableSpan> = spanned.getSpans(start, end, SpoilerClickableSpan::class.java)
.associateBy { (spanned.getSpanStart(it) to spanned.getSpanEnd(it)) }
return spoilerAnnotations.mapNotNull { (position, annotation) ->
if (spoilerClickSpans[position]?.spoilerRevealed != true && !revealedSpoilers.contains(annotation.value)) {
annotation
} else {
revealedSpoilers.add(annotation.value)
null
return spoilerAnnotations
.map { (position, annotation) ->
annotation to spoilerClickSpans[position]
}
}
.toMap()
}
@JvmStatic
@@ -57,18 +53,12 @@ object SpoilerAnnotation {
revealedSpoilers.clear()
}
class SpoilerClickableSpan(spoiler: Annotation) : ClickableSpan() {
private val spoiler: Annotation
var spoilerRevealed = false
private set
init {
this.spoiler = spoiler
spoilerRevealed = revealedSpoilers.contains(spoiler.value)
}
class SpoilerClickableSpan(private val spoiler: Annotation) : ClickableSpan() {
val spoilerRevealed
get() = revealedSpoilers.contains(spoiler.value)
override fun onClick(widget: View) {
spoilerRevealed = true
revealedSpoilers.add(spoiler.value)
}
override fun updateDrawState(ds: TextPaint) {

View File

@@ -9,6 +9,7 @@ import android.text.Layout
import android.text.Spanned
import android.view.animation.LinearInterpolator
import android.widget.TextView
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation.SpoilerClickableSpan
import org.thoughtcrime.securesms.components.spoiler.SpoilerRenderer.MultiLineSpoilerRenderer
import org.thoughtcrime.securesms.components.spoiler.SpoilerRenderer.SingleLineSpoilerRenderer
@@ -26,7 +27,7 @@ class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextVi
private var spoilerDrawablePool = mutableMapOf<Annotation, List<SpoilerDrawable>>()
private var nextSpoilerDrawablePool = mutableMapOf<Annotation, List<SpoilerDrawable>>()
private val cachedAnnotations = HashMap<Int, List<Annotation>>()
private val cachedAnnotations = HashMap<Int, Map<Annotation, SpoilerClickableSpan?>>()
private val cachedMeasurements = HashMap<Int, SpanMeasurements>()
private val animator = ValueAnimator.ofInt(0, 100).apply {
@@ -56,10 +57,14 @@ class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextVi
fun draw(canvas: Canvas, text: Spanned, layout: Layout) {
var hasSpoilersToRender = false
val annotations: List<Annotation> = cachedAnnotations.getFromCache(text) { SpoilerAnnotation.getSpoilerAnnotations(text) }
val annotations: Map<Annotation, SpoilerClickableSpan?> = cachedAnnotations.getFromCache(text) { SpoilerAnnotation.getSpoilerAndClickAnnotations(text) }
nextSpoilerDrawablePool.clear()
for (annotation in annotations) {
for ((annotation, clickSpan) in annotations.entries) {
if (clickSpan?.spoilerRevealed == true) {
continue
}
val spanStart: Int = text.getSpanStart(annotation)
val spanEnd: Int = text.getSpanEnd(annotation)
if (spanStart >= spanEnd) {