Skip to content

Commit

Permalink
Fix rendering of links and mentions covered by spoilers.
Browse files Browse the repository at this point in the history
  • Loading branch information
cody-signal committed Mar 24, 2023
1 parent 168e37c commit 7eb00e4
Show file tree
Hide file tree
Showing 15 changed files with 154 additions and 50 deletions.
Expand Up @@ -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) {
Expand Down
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -163,6 +166,7 @@ private void initialize(@Nullable AttributeSet attrs) {

setMessageType(messageType);

bodyView.enableSpoilerFiltering();
dismissView.setOnClickListener(view -> setVisibility(GONE));
}

Expand Down
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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());
Expand All @@ -120,6 +129,7 @@ protected void onDraw(Canvas canvas) {
}
}
super.onDraw(canvas);
isInOnDraw = false;
}

@Override
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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);
}
}
}
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
Expand Up @@ -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

Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
Expand Up @@ -342,6 +342,7 @@ protected void onFinishInflate() {

bodyText.setOnLongClickListener(passthroughClickListener);
bodyText.setOnClickListener(passthroughClickListener);
bodyText.enableSpoilerFiltering();
footer.setOnTouchDelegateChangedListener(touchDelegateChangedListener);
}

Expand Down
Expand Up @@ -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,
Expand Down
Expand Up @@ -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 {
Expand All @@ -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()
}
Expand All @@ -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)
}
Expand Down
Expand Up @@ -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)
Expand Down
Expand Up @@ -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<SpannableString> finalBody = Transformations.map(createFinalBodyWithMediaIcon(context, body, thread, glideRequests, thumbSize, thumbTarget), updatedBody -> {
Expand Down
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Expand Up @@ -256,11 +256,11 @@ private List<ThreadRecord> getMatchingThreads(@NonNull Collection<RecipientId> 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()));
Expand Down Expand Up @@ -302,7 +302,7 @@ private List<ThreadRecord> getMatchingThreads(@NonNull Collection<RecipientId> 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;

Expand All @@ -326,7 +326,7 @@ private void updateSnippetWithStyles(@NonNull CharSequence body, @NonNull Spanna
}
}

MessageStyler.style(builder.build(), bodySnippet);
MessageStyler.style(id, builder.build(), bodySnippet);
}
}

Expand Down Expand Up @@ -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();
Expand Down
Expand Up @@ -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)
}
Expand Down
Expand Up @@ -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
Expand Down

0 comments on commit 7eb00e4

Please sign in to comment.