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) {

View File

@@ -342,6 +342,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
bodyText.setOnLongClickListener(passthroughClickListener);
bodyText.setOnClickListener(passthroughClickListener);
bodyText.enableSpoilerFiltering();
footer.setOnTouchDelegateChangedListener(touchDelegateChangedListener);
}

View File

@@ -150,7 +150,7 @@ public class ConversationMessage {
: BodyRangeUtil.adjustBodyRanges(messageRecord.getMessageRanges(), mentionsUpdate.getBodyAdjustments());
styledAndMentionBody = SpannableString.valueOf(mentionsUpdate != null ? mentionsUpdate.getBody() : body);
styleResult = MessageStyler.style(bodyRanges, styledAndMentionBody);
styleResult = MessageStyler.style(messageRecord.getId(), bodyRanges, styledAndMentionBody);
}
return new ConversationMessage(messageRecord,

View File

@@ -19,6 +19,9 @@ object MessageStyler {
const val MONOSPACE = "monospace"
const val SPAN_FLAGS = Spanned.SPAN_EXCLUSIVE_INCLUSIVE
const val DRAFT_ID = "DRAFT"
const val COMPOSE_ID = "COMPOSE"
const val QUOTE_ID = "QUOTE"
@JvmStatic
fun boldStyle(): CharacterStyle {
@@ -41,13 +44,13 @@ object MessageStyler {
}
@JvmStatic
fun spoilerStyle(start: Int, length: Int, body: Spannable? = null): Annotation {
return SpoilerAnnotation.spoilerAnnotation(arrayOf(start, length, body?.toString()).contentHashCode())
fun spoilerStyle(id: Any, start: Int, length: Int, body: Spannable? = null): Annotation {
return SpoilerAnnotation.spoilerAnnotation(arrayOf(id, start, length, body?.toString()).contentHashCode())
}
@JvmStatic
@JvmOverloads
fun style(messageRanges: BodyRangeList?, span: Spannable, hideSpoilerText: Boolean = true): Result {
fun style(id: Any, messageRanges: BodyRangeList?, span: Spannable, hideSpoilerText: Boolean = true): Result {
if (messageRanges == null) {
return Result.none()
}
@@ -67,7 +70,7 @@ object MessageStyler {
BodyRangeList.BodyRange.Style.STRIKETHROUGH -> strikethroughStyle()
BodyRangeList.BodyRange.Style.MONOSPACE -> monoStyle()
BodyRangeList.BodyRange.Style.SPOILER -> {
val spoiler = spoilerStyle(range.start, range.length, span)
val spoiler = spoilerStyle(id, range.start, range.length, span)
if (hideSpoilerText) {
span.setSpan(SpoilerAnnotation.SpoilerClickableSpan(spoiler), range.start, range.start + range.length, SPAN_FLAGS)
}

View File

@@ -79,7 +79,7 @@ class DraftRepository(
updatedText = SpannableString(updated.body)
MentionAnnotation.setMentionAnnotations(updatedText, updated.mentions)
MessageStyler.style(messageRanges = bodyRanges.adjustBodyRanges(updated.bodyAdjustments), span = updatedText, hideSpoilerText = false)
MessageStyler.style(id = MessageStyler.DRAFT_ID, messageRanges = bodyRanges.adjustBodyRanges(updated.bodyAdjustments), span = updatedText, hideSpoilerText = false)
}
DatabaseDraft(drafts, updatedText)

View File

@@ -652,7 +652,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
return emphasisAdded(context, context.getString(thread.isOutgoing() ? R.string.ThreadRecord_you_deleted_this_message : R.string.ThreadRecord_this_message_was_deleted), defaultTint);
} else {
SpannableStringBuilder sourceBody = new SpannableStringBuilder(thread.getBody());
MessageStyler.style(thread.getBodyRanges(), sourceBody);
MessageStyler.style(thread.getDate(), thread.getBodyRanges(), sourceBody);
CharSequence body = StringUtil.replace(sourceBody, '\n', " ");
LiveData<SpannableString> finalBody = Transformations.map(createFinalBodyWithMediaIcon(context, body, thread, glideRequests, thumbSize, thumbTarget), updatedBody -> {

View File

@@ -67,7 +67,7 @@ import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.attachments.DatabaseAttachment.DisplayOrderComparator
import org.thoughtcrime.securesms.attachments.MmsNotificationAttachment
import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.conversation.MessageStyler.style
import org.thoughtcrime.securesms.conversation.MessageStyler
import org.thoughtcrime.securesms.database.EarlyReceiptCache.Receipt
import org.thoughtcrime.securesms.database.MentionUtil.UpdatedBodyAndMentions
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.attachments
@@ -5109,7 +5109,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val updated: UpdatedBodyAndMentions = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, quoteText, quoteMentions)
val styledText = SpannableString(updated.body)
style(bodyRanges.adjustBodyRanges(updated.bodyAdjustments), styledText)
MessageStyler.style(id = quoteId, messageRanges = bodyRanges.adjustBodyRanges(updated.bodyAdjustments), span = styledText)
quoteText = styledText
quoteMentions = updated.mentions

View File

@@ -256,11 +256,11 @@ public class SearchRepository {
if (ranges != null) {
updatedBody = SpannableString.valueOf(updatedBody);
MessageStyler.style(BodyRangeUtil.adjustBodyRanges(ranges, bodyAdjustments), (Spannable) updatedBody);
MessageStyler.style(result.getMessageId(), BodyRangeUtil.adjustBodyRanges(ranges, bodyAdjustments), (Spannable) updatedBody);
updatedSnippet = SpannableString.valueOf(updatedSnippet);
//noinspection ConstantConditions
updateSnippetWithStyles(updatedBody, (SpannableString) updatedSnippet, BodyRangeUtil.adjustBodyRanges(ranges, snippetAdjustments));
updateSnippetWithStyles(result.getMessageId(), updatedBody, (SpannableString) updatedSnippet, BodyRangeUtil.adjustBodyRanges(ranges, snippetAdjustments));
}
updatedResults.add(new MessageResult(result.getConversationRecipient(), result.getMessageRecipient(), updatedBody, updatedSnippet, result.getThreadId(), result.getMessageId(), result.getReceivedTimestampMs(), result.isMms()));
@@ -302,7 +302,7 @@ public class SearchRepository {
}
}
private void updateSnippetWithStyles(@NonNull CharSequence body, @NonNull SpannableString bodySnippet, @NonNull BodyRangeList bodyRanges) {
private void updateSnippetWithStyles(long id, @NonNull CharSequence body, @NonNull SpannableString bodySnippet, @NonNull BodyRangeList bodyRanges) {
CharSequence cleanSnippet = bodySnippet;
int startOffset = 0;
@@ -326,7 +326,7 @@ public class SearchRepository {
}
}
MessageStyler.style(builder.build(), bodySnippet);
MessageStyler.style(id, builder.build(), bodySnippet);
}
}
@@ -361,7 +361,7 @@ public class SearchRepository {
SpannableString body = new SpannableString(record.getBody());
if (bodyRanges != null) {
MessageStyler.style(bodyRanges, body);
MessageStyler.style(record.getId(), bodyRanges, body);
}
CharSequence updatedBody = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, body, mentions).getBody();

View File

@@ -134,7 +134,7 @@ class StoryTextPostView @JvmOverloads constructor(
} else {
val body = SpannableString(storyTextPost.body)
if (font == TextFont.REGULAR && bodyRanges != null) {
MessageStyler.style(bodyRanges, body)
MessageStyler.style(System.currentTimeMillis(), bodyRanges, body)
}
setText(body, false)
}

View File

@@ -815,7 +815,7 @@ class StoryViewerPageFragment :
val displayBodySpan = SpannableString(storyPost.content.attachment.caption ?: "")
val ranges: BodyRangeList? = storyPost.conversationMessage.messageRecord.messageRanges
if (ranges != null && displayBodySpan.isNotEmpty()) {
MessageStyler.style(ranges, displayBodySpan)
MessageStyler.style(storyPost.id, ranges, displayBodySpan)
}
displayBodySpan

View File

@@ -0,0 +1,75 @@
package org.thoughtcrime.securesms.util
import android.text.Annotation
import android.text.Spannable
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation
/**
* Filters the results of [getSpans] to exclude spans covered by an unrevealed spoiler when drawing or
* processing clicks. Since [getSpans] can also be called when making copies of spannables, we do not filter
* the call unless we know we are drawing or getting click spannables.
*/
class SpoilerFilteringSpannable(private val spannable: Spannable, private val inOnDrawProvider: InOnDrawProvider) : Spannable by spannable {
override fun <T : Any> getSpans(start: Int, end: Int, type: Class<T>): Array<T> {
val spans: Array<T> = spannable.getSpans(start, end, type)
if (spans.isEmpty() || !(inOnDrawProvider.isInOnDraw() || type == LongClickCopySpan::class.java)) {
return spans
}
if (spannable.getSpans(0, spannable.length, Annotation::class.java).none { SpoilerAnnotation.isSpoilerAnnotation(it) }) {
return spans
}
val spansToExclude = HashSet<Any>()
val spoilers: Map<Annotation, SpoilerAnnotation.SpoilerClickableSpan?> = SpoilerAnnotation.getSpoilerAndClickAnnotations(spannable, start, end)
val allOtherTheSpans: Map<T, Pair<Int, Int>> = spans
.filterNot { SpoilerAnnotation.isSpoilerAnnotation(it) || it is SpoilerAnnotation.SpoilerClickableSpan }
.associateWith { (spannable.getSpanStart(it) to spannable.getSpanEnd(it)) }
spoilers.forEach { (spoiler, click) ->
if (click?.spoilerRevealed == true) {
spansToExclude += spoiler
spansToExclude += click
} else {
val spoilerStart = spannable.getSpanStart(spoiler)
val spoilerEnd = spannable.getSpanEnd(spoiler)
for ((span, position) in allOtherTheSpans) {
if (position.first in spoilerStart..spoilerEnd) {
spansToExclude += span
} else if (position.second in spoilerStart..spoilerEnd) {
spansToExclude += span
}
}
}
}
return spans.filter(spansToExclude)
}
/**
* Kotlin does not handle generic JVM arrays well so instead of using all the nice collection functions
* we do a move desired objects down and overwrite undesired objects and then copy the array to trim
* it to the correct length. For our use case, it's okay to modify the original array.
*/
private fun <T : Any> Array<T>.filter(set: Set<Any>): Array<T> {
var index = 0
for (i in this.indices) {
this[index] = this[i]
if (!set.contains(this[index])) {
index++
}
}
return copyOfRange(0, index)
}
override fun toString(): String = spannable.toString()
override fun hashCode(): Int = spannable.hashCode()
override fun equals(other: Any?): Boolean = spannable == other
interface InOnDrawProvider {
fun isInOnDraw(): Boolean
}
}