From 7eb00e41a2647392b48668a6fcc005d86733807e Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Fri, 24 Mar 2023 14:44:21 -0400 Subject: [PATCH] Fix rendering of links and mentions covered by spoilers. --- .../securesms/components/ComposeText.java | 2 +- .../securesms/components/QuoteView.java | 6 +- .../components/emoji/EmojiTextView.java | 38 ++++++++-- .../components/spoiler/SpoilerAnnotation.kt | 36 ++++----- .../spoiler/SpoilerRendererDelegate.kt | 11 ++- .../conversation/ConversationItem.java | 1 + .../conversation/ConversationMessage.java | 2 +- .../securesms/conversation/MessageStyler.kt | 11 ++- .../conversation/drafts/DraftRepository.kt | 2 +- .../ConversationListItem.java | 2 +- .../securesms/database/MessageTable.kt | 4 +- .../securesms/search/SearchRepository.java | 10 +-- .../securesms/stories/StoryTextPostView.kt | 2 +- .../viewer/page/StoryViewerPageFragment.kt | 2 +- .../util/SpoilerFilteringSpannable.kt | 75 +++++++++++++++++++ 15 files changed, 154 insertions(+), 50 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/SpoilerFilteringSpannable.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java index f59f99ea8bb..6e735897ed2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java @@ -363,7 +363,7 @@ public boolean onActionItemClicked(ActionMode mode, MenuItem item) { } 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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java index 6230a4d8280..e64274785f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java @@ -5,6 +5,7 @@ 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.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 enum MessageType { 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 @@ private void initialize(@Nullable AttributeSet attrs) { setMessageType(messageType); + bodyView.enableSpoilerFiltering(); dismissView.setOnClickListener(view -> setVisibility(GONE)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java index 234d1287e4d..9e21be37ff5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java @@ -7,6 +7,7 @@ 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.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.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 EmojiTextView(Context context, AttributeSet attrs, int defStyleAttr) { 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 @@ protected void onDraw(Canvas canvas) { } } super.onDraw(canvas); + isInOnDraw = false; } @Override @@ -151,13 +161,18 @@ public void setText(@Nullable CharSequence text, BufferType type) { 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 void setMentionBackgroundTint(@ColorInt int mentionBackgroundTint) { 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); + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerAnnotation.kt b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerAnnotation.kt index ada272afc44..a46b2bf4eec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerAnnotation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerAnnotation.kt @@ -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 { - val spoilerAnnotations: Map, Annotation> = spanned.getSpans(0, spanned.length, Annotation::class.java) + fun getSpoilerAndClickAnnotations(spanned: Spanned, start: Int = 0, end: Int = spanned.length): Map { + val spoilerAnnotations: Map, Annotation> = spanned.getSpans(start, end, Annotation::class.java) .filter { isSpoilerAnnotation(it) } .associateBy { (spanned.getSpanStart(it) to spanned.getSpanEnd(it)) } - val spoilerClickSpans: Map, SpoilerClickableSpan> = spanned.getSpans(0, spanned.length, SpoilerClickableSpan::class.java) + val spoilerClickSpans: Map, 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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerRendererDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerRendererDelegate.kt index 56bb4114fe2..235eaa662b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerRendererDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerRendererDelegate.kt @@ -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>() private var nextSpoilerDrawablePool = mutableMapOf>() - private val cachedAnnotations = HashMap>() + private val cachedAnnotations = HashMap>() private val cachedMeasurements = HashMap() 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 = cachedAnnotations.getFromCache(text) { SpoilerAnnotation.getSpoilerAnnotations(text) } + val annotations: Map = 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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 7f5187fb0ec..80319876924 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -342,6 +342,7 @@ protected void onFinishInflate() { bodyText.setOnLongClickListener(passthroughClickListener); bodyText.setOnClickListener(passthroughClickListener); + bodyText.enableSpoilerFiltering(); footer.setOnTouchDelegateChangedListener(touchDelegateChangedListener); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java index 6464d9a9480..bcfb4e53617 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java @@ -150,7 +150,7 @@ public static class ConversationMessageFactory { : 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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MessageStyler.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/MessageStyler.kt index 93f8061cc55..6d3fc49e102 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MessageStyler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MessageStyler.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt index c18d4950748..8f734a6ad94 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java index f6bb1fa6b84..7208f3defdd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java @@ -652,7 +652,7 @@ private void onRecipientChanged(@NonNull Recipient recipient) { 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 finalBody = Transformations.map(createFinalBodyWithMediaIcon(context, body, thread, glideRequests, thumbSize, thumbTarget), updatedBody -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index d4e393d7927..46c1df542f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java index 425f058f8c7..5331caa6c4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java @@ -256,11 +256,11 @@ private List getMatchingThreads(@NonNull Collection r 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 @@ private List getMatchingThreads(@NonNull Collection r } } - 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 @@ private void updateSnippetWithStyles(@NonNull CharSequence body, @NonNull Spanna } } - MessageStyler.style(builder.build(), bodySnippet); + MessageStyler.style(id, builder.build(), bodySnippet); } } @@ -361,7 +361,7 @@ private void updateSnippetWithStyles(@NonNull CharSequence body, @NonNull Spanna 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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt index e6180a71a60..1e2979c6765 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt index 58149441269..5f1d10abcbd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SpoilerFilteringSpannable.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SpoilerFilteringSpannable.kt new file mode 100644 index 00000000000..950dab5a7b6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SpoilerFilteringSpannable.kt @@ -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 getSpans(start: Int, end: Int, type: Class): Array { + val spans: Array = 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() + val spoilers: Map = SpoilerAnnotation.getSpoilerAndClickAnnotations(spannable, start, end) + val allOtherTheSpans: Map> = 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 Array.filter(set: Set): Array { + 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 + } +}