Complete text formatting.
This commit is contained in:
parent
534c5c3c64
commit
a64bffd83a
20 changed files with 211 additions and 271 deletions
|
@ -302,9 +302,7 @@ public class ComposeText extends EmojiEditText {
|
|||
addTextChangedListener(mentionValidatorWatcher);
|
||||
|
||||
if (FeatureFlags.textFormatting()) {
|
||||
if (FeatureFlags.textFormattingSpoilerSend()) {
|
||||
spoilerRendererDelegate = new SpoilerRendererDelegate(this, true);
|
||||
}
|
||||
spoilerRendererDelegate = new SpoilerRendererDelegate(this, true);
|
||||
|
||||
addTextChangedListener(new ComposeTextStyleWatcher());
|
||||
|
||||
|
@ -323,10 +321,7 @@ public class ComposeText extends EmojiEditText {
|
|||
menu.add(0, R.id.edittext_italic, largestOrder, getContext().getString(R.string.TextFormatting_italic));
|
||||
menu.add(0, R.id.edittext_strikethrough, largestOrder, getContext().getString(R.string.TextFormatting_strikethrough));
|
||||
menu.add(0, R.id.edittext_monospace, largestOrder, getContext().getString(R.string.TextFormatting_monospace));
|
||||
|
||||
if (FeatureFlags.textFormattingSpoilerSend()) {
|
||||
menu.add(0, R.id.edittext_spoiler, largestOrder, getContext().getString(R.string.TextFormatting_spoiler));
|
||||
}
|
||||
menu.add(0, R.id.edittext_spoiler, largestOrder, getContext().getString(R.string.TextFormatting_spoiler));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
@ -5,7 +5,6 @@ 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;
|
||||
|
@ -31,7 +30,6 @@ 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;
|
||||
|
@ -166,7 +164,6 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
|||
|
||||
setMessageType(messageType);
|
||||
|
||||
bodyView.enableSpoilerFiltering();
|
||||
dismissView.setOnClickListener(view -> setVisibility(GONE));
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.components.emoji;
|
|||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.Region;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Build;
|
||||
import android.text.Annotation;
|
||||
|
@ -70,9 +72,8 @@ public class EmojiTextView extends AppCompatTextView {
|
|||
private boolean forceJumboEmoji;
|
||||
private boolean isInOnDraw;
|
||||
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
private final SpoilerRendererDelegate spoilerRendererDelegate;
|
||||
private SpoilerFilteringSpannableFactory spoilerFilteringSpannableFactory;
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
private final SpoilerRendererDelegate spoilerRendererDelegate;
|
||||
|
||||
public EmojiTextView(Context context) {
|
||||
this(context, null);
|
||||
|
@ -113,11 +114,6 @@ public class EmojiTextView extends AppCompatTextView {
|
|||
setText(getText());
|
||||
}
|
||||
|
||||
public void enableSpoilerFiltering() {
|
||||
spoilerFilteringSpannableFactory = new SpoilerFilteringSpannableFactory(() -> isInOnDraw);
|
||||
setSpannableFactory(spoilerFilteringSpannableFactory);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
isInOnDraw = true;
|
||||
|
@ -126,7 +122,10 @@ public class EmojiTextView extends AppCompatTextView {
|
|||
boolean hasLayout = getLayout() != null;
|
||||
|
||||
if (hasSpannedText && hasLayout) {
|
||||
drawSpecialRenderers(canvas, mentionRendererDelegate, spoilerRendererDelegate);
|
||||
Path textClipPath = drawSpecialRenderers(canvas, mentionRendererDelegate, spoilerRendererDelegate);
|
||||
canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
|
||||
canvas.clipPath(textClipPath, Region.Op.DIFFERENCE);
|
||||
canvas.translate(-getTotalPaddingLeft(), -getTotalPaddingTop());
|
||||
}
|
||||
|
||||
super.onDraw(canvas);
|
||||
|
@ -138,14 +137,14 @@ public class EmojiTextView extends AppCompatTextView {
|
|||
isInOnDraw = false;
|
||||
}
|
||||
|
||||
private void drawSpecialRenderers(@NonNull Canvas canvas, @Nullable MentionRendererDelegate mentionDelegate, @NonNull SpoilerRendererDelegate spoilerDelegate) {
|
||||
private Path drawSpecialRenderers(@NonNull Canvas canvas, @Nullable MentionRendererDelegate mentionDelegate, @NonNull SpoilerRendererDelegate spoilerDelegate) {
|
||||
int checkpoint = canvas.save();
|
||||
canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
|
||||
try {
|
||||
if (mentionDelegate != null) {
|
||||
mentionDelegate.draw(canvas, (Spanned) getText(), getLayout());
|
||||
}
|
||||
spoilerDelegate.draw(canvas, (Spanned) getText(), getLayout());
|
||||
return spoilerDelegate.draw(canvas, (Spanned) getText(), getLayout());
|
||||
} finally {
|
||||
canvas.restoreToCount(checkpoint);
|
||||
}
|
||||
|
@ -187,9 +186,6 @@ public class EmojiTextView extends AppCompatTextView {
|
|||
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)
|
||||
|
@ -333,11 +329,7 @@ public class EmojiTextView extends AppCompatTextView {
|
|||
newTextToSet = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji);
|
||||
}
|
||||
|
||||
if (spoilerFilteringSpannableFactory != null) {
|
||||
spoilerFilteringSpannableFactory.wrap(newTextToSet);
|
||||
}
|
||||
|
||||
super.setText(newContent, BufferType.SPANNABLE);
|
||||
super.setText(newTextToSet, BufferType.SPANNABLE);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,8 @@ package org.thoughtcrime.securesms.components.emoji
|
|||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.text.Spannable
|
||||
import android.graphics.Path
|
||||
import android.graphics.Region
|
||||
import android.text.Spanned
|
||||
import android.text.TextUtils
|
||||
import android.util.AttributeSet
|
||||
|
@ -21,8 +22,6 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
|
|||
private var bufferType: BufferType? = null
|
||||
private val sizeChangeDebouncer: ThrottledDebouncer = ThrottledDebouncer(200)
|
||||
private val spoilerRendererDelegate: SpoilerRendererDelegate
|
||||
private var spoilerFilteringSpannableFactory: SpoilerFilteringSpannableFactory? = null
|
||||
private var isInOnDraw: Boolean = false
|
||||
|
||||
init {
|
||||
isEmojiCompatEnabled = isInEditMode || SignalStore.settings().isPreferSystemEmoji
|
||||
|
@ -30,20 +29,24 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
|
|||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
isInOnDraw = true
|
||||
|
||||
var textClipPath: Path? = null
|
||||
if (text is Spanned && layout != null) {
|
||||
val checkpoint = canvas.save()
|
||||
canvas.translate(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat())
|
||||
try {
|
||||
spoilerRendererDelegate.draw(canvas, (text as Spanned), layout)
|
||||
textClipPath = spoilerRendererDelegate.draw(canvas, (text as Spanned), layout)
|
||||
} finally {
|
||||
canvas.restoreToCount(checkpoint)
|
||||
}
|
||||
}
|
||||
super.onDraw(canvas)
|
||||
|
||||
isInOnDraw = false
|
||||
if (textClipPath != null) {
|
||||
canvas.translate(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat())
|
||||
canvas.clipPath(textClipPath, Region.Op.DIFFERENCE)
|
||||
canvas.translate(-totalPaddingLeft.toFloat(), -totalPaddingTop.toFloat())
|
||||
}
|
||||
|
||||
super.onDraw(canvas)
|
||||
}
|
||||
|
||||
override fun setText(text: CharSequence?, type: BufferType?) {
|
||||
|
@ -69,10 +72,6 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
|
|||
TextUtils.ellipsize(newText, paint, (adjustedWidth * maxLines).toFloat(), TextUtils.TruncateAt.END, false, null)
|
||||
}
|
||||
|
||||
if (newContent is Spannable && spoilerFilteringSpannableFactory != null) {
|
||||
newContent = spoilerFilteringSpannableFactory!!.wrap(newContent)
|
||||
}
|
||||
|
||||
bufferType = BufferType.SPANNABLE
|
||||
super.setText(newContent, type)
|
||||
}
|
||||
|
@ -86,9 +85,4 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun enableSpoilerFiltering() {
|
||||
spoilerFilteringSpannableFactory = SpoilerFilteringSpannableFactory { isInOnDraw }
|
||||
setSpannableFactory(spoilerFilteringSpannableFactory!!)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
package org.thoughtcrime.securesms.components.emoji
|
||||
|
||||
import android.text.Spannable
|
||||
import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable
|
||||
import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable.InOnDrawProvider
|
||||
|
||||
/**
|
||||
* Spannable factory used to help ensure spans are copied/maintained properly through the
|
||||
* Android text handling system.
|
||||
*
|
||||
* @param inOnDraw Used by [SpoilerFilteringSpannable] to remove spans when being called from onDraw
|
||||
*/
|
||||
class SpoilerFilteringSpannableFactory(private val inOnDraw: InOnDrawProvider) : Spannable.Factory() {
|
||||
override fun newSpannable(source: CharSequence): Spannable {
|
||||
return wrap(super.newSpannable(source))
|
||||
}
|
||||
|
||||
fun wrap(source: Spannable): SpoilerFilteringSpannable {
|
||||
return SpoilerFilteringSpannable(source, inOnDraw)
|
||||
}
|
||||
}
|
|
@ -1,10 +1,8 @@
|
|||
package org.thoughtcrime.securesms.components.spoiler
|
||||
|
||||
import android.graphics.Color
|
||||
import android.text.Annotation
|
||||
import android.text.Selection
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
import android.text.TextPaint
|
||||
import android.text.style.ClickableSpan
|
||||
|
@ -63,21 +61,15 @@ object SpoilerAnnotation {
|
|||
|
||||
override fun onClick(widget: View) {
|
||||
revealedSpoilers.add(spoiler.value)
|
||||
if (widget is TextView && Selection.getSelectionStart(widget.text) != -1) {
|
||||
val text: Spannable = if (widget.text is Spannable) {
|
||||
widget.text as Spannable
|
||||
} else {
|
||||
SpannableString(widget.text)
|
||||
|
||||
if (widget is TextView) {
|
||||
val text = widget.text
|
||||
if (text is Spannable) {
|
||||
Selection.removeSelection(text)
|
||||
}
|
||||
Selection.removeSelection(text)
|
||||
widget.text = text
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateDrawState(ds: TextPaint) {
|
||||
if (!spoilerRevealed) {
|
||||
ds.color = Color.TRANSPARENT
|
||||
}
|
||||
}
|
||||
override fun updateDrawState(ds: TextPaint) = Unit
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,109 +1,123 @@
|
|||
package org.thoughtcrime.securesms.components.spoiler
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.text.Layout
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.Px
|
||||
import org.thoughtcrime.securesms.util.LayoutUtil
|
||||
|
||||
/**
|
||||
* Handles drawing the spoiler sparkles for a TextView.
|
||||
*/
|
||||
abstract class SpoilerRenderer {
|
||||
class SpoilerRenderer(
|
||||
private val spoilerDrawable: SpoilerDrawable,
|
||||
private val renderForComposing: Boolean,
|
||||
@Px private val padding: Int,
|
||||
@ColorInt composeBackgroundColor: Int
|
||||
) {
|
||||
|
||||
abstract fun draw(
|
||||
private val lineTopCache = HashMap<Int, Int>()
|
||||
private val lineBottomCache = HashMap<Int, Int>()
|
||||
private val paint = Paint().apply { color = composeBackgroundColor }
|
||||
|
||||
fun draw(
|
||||
canvas: Canvas,
|
||||
layout: Layout,
|
||||
startLine: Int,
|
||||
endLine: Int,
|
||||
startOffset: Int,
|
||||
endOffset: Int
|
||||
)
|
||||
|
||||
protected fun getLineTop(layout: Layout, line: Int): Int {
|
||||
return LayoutUtil.getLineTopWithoutPadding(layout, line)
|
||||
}
|
||||
|
||||
protected fun getLineBottom(layout: Layout, line: Int): Int {
|
||||
return LayoutUtil.getLineBottomWithoutPadding(layout, line)
|
||||
}
|
||||
|
||||
protected inline fun MutableMap<Int, Int>.get(line: Int, layout: Layout, default: () -> Int): Int {
|
||||
return getOrPut(line * 31 + layout.hashCode() * 31, default)
|
||||
}
|
||||
|
||||
class SingleLineSpoilerRenderer(private val spoilerDrawable: SpoilerDrawable) : SpoilerRenderer() {
|
||||
private val lineTopCache = HashMap<Int, Int>()
|
||||
private val lineBottomCache = HashMap<Int, Int>()
|
||||
|
||||
override fun draw(
|
||||
canvas: Canvas,
|
||||
layout: Layout,
|
||||
startLine: Int,
|
||||
endLine: Int,
|
||||
startOffset: Int,
|
||||
endOffset: Int
|
||||
) {
|
||||
endOffset: Int,
|
||||
textClipPath: Path
|
||||
) {
|
||||
if (startLine == endLine) {
|
||||
val lineTop = lineTopCache.get(startLine, layout) { getLineTop(layout, startLine) }
|
||||
val lineBottom = lineBottomCache.get(startLine, layout) { getLineBottom(layout, startLine) }
|
||||
val left = startOffset.coerceAtMost(endOffset)
|
||||
val right = startOffset.coerceAtLeast(endOffset)
|
||||
|
||||
spoilerDrawable.setBounds(left, lineTop, right, lineBottom)
|
||||
spoilerDrawable.draw(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
class MultiLineSpoilerRenderer(private val spoilerDrawable: SpoilerDrawable) : SpoilerRenderer() {
|
||||
private val lineTopCache = HashMap<Int, Int>()
|
||||
private val lineBottomCache = HashMap<Int, Int>()
|
||||
|
||||
override fun draw(
|
||||
canvas: Canvas,
|
||||
layout: Layout,
|
||||
startLine: Int,
|
||||
endLine: Int,
|
||||
startOffset: Int,
|
||||
endOffset: Int
|
||||
) {
|
||||
val paragraphDirection = layout.getParagraphDirection(startLine)
|
||||
|
||||
val lineEndOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineLeft(startLine) else layout.getLineRight(startLine)
|
||||
var lineBottom = lineBottomCache.get(startLine, layout) { getLineBottom(layout, startLine) }
|
||||
var lineTop = lineTopCache.get(startLine, layout) { getLineTop(layout, startLine) }
|
||||
drawStart(canvas, startOffset, lineTop, lineEndOffset.toInt(), lineBottom)
|
||||
|
||||
for (line in startLine + 1 until endLine) {
|
||||
val left: Int = layout.getLineLeft(line).toInt()
|
||||
val right: Int = layout.getLineRight(line).toInt()
|
||||
|
||||
lineTop = getLineTop(layout, line)
|
||||
lineBottom = getLineBottom(layout, line)
|
||||
|
||||
if (renderForComposing) {
|
||||
canvas.drawComposeBackground(left, lineTop, right, lineBottom)
|
||||
} else {
|
||||
textClipPath.addRect(left, lineTop, right, lineBottom)
|
||||
spoilerDrawable.setBounds(left, lineTop, right, lineBottom)
|
||||
spoilerDrawable.draw(canvas)
|
||||
}
|
||||
|
||||
val lineStartOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineRight(startLine) else layout.getLineLeft(startLine)
|
||||
lineBottom = lineBottomCache.get(endLine, layout) { getLineBottom(layout, endLine) }
|
||||
lineTop = lineTopCache.get(endLine, layout) { getLineTop(layout, endLine) }
|
||||
drawEnd(canvas, lineStartOffset.toInt(), lineTop, endOffset, lineBottom)
|
||||
return
|
||||
}
|
||||
|
||||
private fun drawStart(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int) {
|
||||
if (start > end) {
|
||||
spoilerDrawable.setBounds(end, top, start, bottom)
|
||||
val paragraphDirection = layout.getParagraphDirection(startLine)
|
||||
|
||||
val lineEndOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineLeft(startLine) else layout.getLineRight(startLine)
|
||||
var lineBottom = lineBottomCache.get(startLine, layout) { getLineBottom(layout, startLine) }
|
||||
var lineTop = lineTopCache.get(startLine, layout) { getLineTop(layout, startLine) }
|
||||
drawPartialLine(canvas, startOffset, lineTop, lineEndOffset.toInt(), lineBottom, textClipPath)
|
||||
|
||||
for (line in startLine + 1 until endLine) {
|
||||
val left: Int = layout.getLineLeft(line).toInt()
|
||||
val right: Int = layout.getLineRight(line).toInt()
|
||||
|
||||
lineTop = getLineTop(layout, line)
|
||||
lineBottom = getLineBottom(layout, line)
|
||||
|
||||
if (renderForComposing) {
|
||||
canvas.drawComposeBackground(left, lineTop, right, lineBottom)
|
||||
} else {
|
||||
spoilerDrawable.setBounds(start, top, end, bottom)
|
||||
textClipPath.addRect(left, lineTop, right, lineBottom)
|
||||
spoilerDrawable.setBounds(left, lineTop, right, lineBottom)
|
||||
spoilerDrawable.draw(canvas)
|
||||
}
|
||||
spoilerDrawable.draw(canvas)
|
||||
}
|
||||
|
||||
private fun drawEnd(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int) {
|
||||
if (start > end) {
|
||||
spoilerDrawable.setBounds(end, top, start, bottom)
|
||||
} else {
|
||||
spoilerDrawable.setBounds(start, top, end, bottom)
|
||||
}
|
||||
spoilerDrawable.draw(canvas)
|
||||
val lineStartOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineRight(startLine) else layout.getLineLeft(startLine)
|
||||
lineBottom = lineBottomCache.get(endLine, layout) { getLineBottom(layout, endLine) }
|
||||
lineTop = lineTopCache.get(endLine, layout) { getLineTop(layout, endLine) }
|
||||
drawPartialLine(canvas, lineStartOffset.toInt(), lineTop, endOffset, lineBottom, textClipPath)
|
||||
}
|
||||
|
||||
private fun drawPartialLine(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int, textClipPath: Path) {
|
||||
if (renderForComposing) {
|
||||
canvas.drawComposeBackground(start, top, end, bottom)
|
||||
return
|
||||
}
|
||||
|
||||
if (start > end) {
|
||||
textClipPath.addRect(end, top, start, bottom)
|
||||
spoilerDrawable.setBounds(end, top, start, bottom)
|
||||
} else {
|
||||
textClipPath.addRect(start, top, end, bottom)
|
||||
spoilerDrawable.setBounds(start, top, end, bottom)
|
||||
}
|
||||
spoilerDrawable.draw(canvas)
|
||||
}
|
||||
|
||||
private fun getLineTop(layout: Layout, line: Int): Int {
|
||||
return LayoutUtil.getLineTopWithoutPadding(layout, line)
|
||||
}
|
||||
|
||||
private fun getLineBottom(layout: Layout, line: Int): Int {
|
||||
return LayoutUtil.getLineBottomWithoutPadding(layout, line)
|
||||
}
|
||||
|
||||
private inline fun MutableMap<Int, Int>.get(line: Int, layout: Layout, default: () -> Int): Int {
|
||||
return getOrPut(line * 31 + layout.hashCode() * 31, default)
|
||||
}
|
||||
|
||||
private fun Canvas.drawComposeBackground(start: Int, top: Int, end: Int, bottom: Int) {
|
||||
drawRoundRect(
|
||||
start.toFloat() - padding,
|
||||
top.toFloat() - padding,
|
||||
end.toFloat() + padding,
|
||||
bottom.toFloat(),
|
||||
padding.toFloat(),
|
||||
padding.toFloat(),
|
||||
paint
|
||||
)
|
||||
}
|
||||
|
||||
private fun Path.addRect(left: Int, top: Int, end: Int, bottom: Int) {
|
||||
addRect(left.toFloat() - padding, top.toFloat() - padding, end.toFloat() + padding, bottom.toFloat() + padding, Path.Direction.CCW)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.spoiler
|
|||
|
||||
import android.animation.ValueAnimator
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Path
|
||||
import android.text.Annotation
|
||||
import android.text.Layout
|
||||
import android.text.Spanned
|
||||
|
@ -9,18 +10,17 @@ import android.view.View
|
|||
import android.view.View.OnAttachStateChangeListener
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.signal.core.util.dp
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation.SpoilerClickableSpan
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerRenderer.MultiLineSpoilerRenderer
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerRenderer.SingleLineSpoilerRenderer
|
||||
|
||||
/**
|
||||
* Performs initial calculation on how to render spoilers and then delegates to the single line or
|
||||
* multi-line version of actually drawing the spoiler sparkles.
|
||||
* Performs initial calculation on how to render spoilers and then delegates to actually drawing the spoiler sparkles.
|
||||
*/
|
||||
class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextView, private val renderForComposing: Boolean = false) {
|
||||
|
||||
private val single: SpoilerRenderer
|
||||
private val multi: SpoilerRenderer
|
||||
private val renderer: SpoilerRenderer
|
||||
private val spoilerDrawable: SpoilerDrawable
|
||||
private var animatorRunning = false
|
||||
private var textColor: Int
|
||||
|
@ -39,11 +39,17 @@ class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextVi
|
|||
repeatMode = ValueAnimator.REVERSE
|
||||
}
|
||||
|
||||
private val textClipPath: Path = Path()
|
||||
|
||||
init {
|
||||
textColor = view.textColors.defaultColor
|
||||
spoilerDrawable = SpoilerDrawable(textColor)
|
||||
single = SingleLineSpoilerRenderer(spoilerDrawable)
|
||||
multi = MultiLineSpoilerRenderer(spoilerDrawable)
|
||||
renderer = SpoilerRenderer(
|
||||
spoilerDrawable = spoilerDrawable,
|
||||
renderForComposing = renderForComposing,
|
||||
padding = 2.dp,
|
||||
composeBackgroundColor = ContextCompat.getColor(view.context, R.color.signal_colorOnSurfaceVariant1)
|
||||
)
|
||||
|
||||
view.addOnAttachStateChangeListener(object : OnAttachStateChangeListener {
|
||||
override fun onViewDetachedFromWindow(v: View) = stopAnimating()
|
||||
|
@ -59,10 +65,11 @@ class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextVi
|
|||
}
|
||||
}
|
||||
|
||||
fun draw(canvas: Canvas, text: Spanned, layout: Layout) {
|
||||
fun draw(canvas: Canvas, text: Spanned, layout: Layout): Path {
|
||||
var hasSpoilersToRender = false
|
||||
val annotations: Map<Annotation, SpoilerClickableSpan?> = cachedAnnotations.getFromCache(text) { SpoilerAnnotation.getSpoilerAndClickAnnotations(text) }
|
||||
|
||||
textClipPath.reset()
|
||||
for ((annotation, clickSpan) in annotations.entries) {
|
||||
if (clickSpan?.spoilerRevealed == true) {
|
||||
continue
|
||||
|
@ -85,9 +92,7 @@ class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextVi
|
|||
)
|
||||
}
|
||||
|
||||
val renderer: SpoilerRenderer = if (measurements.startLine == measurements.endLine) single else multi
|
||||
|
||||
renderer.draw(canvas, layout, measurements.startLine, measurements.endLine, measurements.startOffset, measurements.endOffset)
|
||||
renderer.draw(canvas, layout, measurements.startLine, measurements.endLine, measurements.startOffset, measurements.endOffset, textClipPath)
|
||||
hasSpoilersToRender = true
|
||||
}
|
||||
|
||||
|
@ -99,6 +104,8 @@ class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextVi
|
|||
} else {
|
||||
stopAnimating()
|
||||
}
|
||||
|
||||
return textClipPath
|
||||
}
|
||||
|
||||
private fun stopAnimating() {
|
||||
|
|
|
@ -91,7 +91,6 @@ import org.thoughtcrime.securesms.components.ThumbnailView;
|
|||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectCollection;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
|
||||
|
@ -342,7 +341,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
|
||||
bodyText.setOnLongClickListener(passthroughClickListener);
|
||||
bodyText.setOnClickListener(passthroughClickListener);
|
||||
bodyText.enableSpoilerFiltering();
|
||||
footer.setOnTouchDelegateChangedListener(touchDelegateChangedListener);
|
||||
}
|
||||
|
||||
|
|
|
@ -171,8 +171,6 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
|
|||
this.thumbTarget = new GlideLiveDataTarget(thumbSize, thumbSize);
|
||||
this.searchStyleFactory = () -> new CharacterStyle[] { new ForegroundColorSpan(ContextCompat.getColor(getContext(), R.color.signal_colorOnSurface)), SpanUtil.getBoldSpan() };
|
||||
|
||||
this.subjectView.enableSpoilerFiltering();
|
||||
|
||||
getLayoutTransition().setDuration(150);
|
||||
}
|
||||
|
||||
|
|
|
@ -130,7 +130,6 @@ public class LongMessageFragment extends FullScreenDialogFragment {
|
|||
SpannableString styledBody = linkifyMessageBody(new SpannableString(trimmedBody));
|
||||
|
||||
bubble.setVisibility(View.VISIBLE);
|
||||
text.enableSpoilerFiltering();
|
||||
text.setText(styledBody);
|
||||
text.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
text.setTextSize(TypedValue.COMPLEX_UNIT_SP, SignalStore.settings().getMessageFontSize());
|
||||
|
|
|
@ -15,8 +15,6 @@ import org.signal.core.util.logging.Log;
|
|||
import org.thoughtcrime.securesms.BuildConfig;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob;
|
||||
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob;
|
||||
import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.messageprocessingalarm.MessageProcessReceiver;
|
||||
|
@ -107,7 +105,6 @@ public final class FeatureFlags {
|
|||
private static final String TEXT_FORMATTING = "android.textFormatting";
|
||||
private static final String ANY_ADDRESS_PORTS_KILL_SWITCH = "android.calling.fieldTrial.anyAddressPortsKillSwitch";
|
||||
private static final String CALLS_TAB = "android.calls.tab.2";
|
||||
private static final String TEXT_FORMATTING_SPOILER_SEND = "android.textFormatting.spoilerSend";
|
||||
private static final String AD_HOC_CALLING = "android.calling.ad.hoc";
|
||||
private static final String EDIT_MESSAGE_RECEIVE = "android.editMessage.receive";
|
||||
private static final String EDIT_MESSAGE_SEND = "android.editMessage.send";
|
||||
|
@ -170,7 +167,6 @@ public final class FeatureFlags {
|
|||
TEXT_FORMATTING,
|
||||
ANY_ADDRESS_PORTS_KILL_SWITCH,
|
||||
CALLS_TAB,
|
||||
TEXT_FORMATTING_SPOILER_SEND,
|
||||
EDIT_MESSAGE_RECEIVE,
|
||||
EDIT_MESSAGE_SEND
|
||||
);
|
||||
|
@ -238,7 +234,6 @@ public final class FeatureFlags {
|
|||
PAYMENTS_REQUEST_ACTIVATE_FLOW,
|
||||
CDS_HARD_LIMIT,
|
||||
TEXT_FORMATTING,
|
||||
TEXT_FORMATTING_SPOILER_SEND,
|
||||
EDIT_MESSAGE_RECEIVE,
|
||||
EDIT_MESSAGE_SEND
|
||||
);
|
||||
|
@ -587,13 +582,6 @@ public final class FeatureFlags {
|
|||
return getBoolean(TEXT_FORMATTING, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether or not we should show spoiler text formatting option.
|
||||
*/
|
||||
public static boolean textFormattingSpoilerSend() {
|
||||
return getBoolean(TEXT_FORMATTING_SPOILER_SEND, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable RingRTC field trial for "AnyAddressPortsKillSwitch"
|
||||
*/
|
||||
|
|
|
@ -15,6 +15,7 @@ import android.widget.TextView;
|
|||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
|
@ -53,8 +54,7 @@ public class LongClickMovementMethod extends LinkMovementMethod {
|
|||
public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) {
|
||||
int action = event.getAction();
|
||||
|
||||
if (action == MotionEvent.ACTION_UP ||
|
||||
action == MotionEvent.ACTION_DOWN) {
|
||||
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
|
||||
int x = (int) event.getX();
|
||||
int y = (int) event.getY();
|
||||
|
||||
|
@ -68,14 +68,31 @@ public class LongClickMovementMethod extends LinkMovementMethod {
|
|||
int line = layout.getLineForVertical(y);
|
||||
int off = layout.getOffsetForHorizontal(line, x);
|
||||
|
||||
LongClickCopySpan longClickCopySpan[] = buffer.getSpans(off, off, LongClickCopySpan.class);
|
||||
SpoilerAnnotation.SpoilerClickableSpan[] spoilerClickableSpans = buffer.getSpans(off, off, SpoilerAnnotation.SpoilerClickableSpan.class);
|
||||
if (spoilerClickableSpans.length != 0) {
|
||||
boolean spoilerRevealed = false;
|
||||
for (SpoilerAnnotation.SpoilerClickableSpan spoilerClickSpan : spoilerClickableSpans) {
|
||||
if (!spoilerClickSpan.getSpoilerRevealed() && action == MotionEvent.ACTION_DOWN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!spoilerClickSpan.getSpoilerRevealed() && action == MotionEvent.ACTION_UP) {
|
||||
spoilerClickSpan.onClick(widget);
|
||||
spoilerRevealed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (spoilerRevealed) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
LongClickCopySpan[] longClickCopySpan = buffer.getSpans(off, off, LongClickCopySpan.class);
|
||||
if (longClickCopySpan.length != 0) {
|
||||
LongClickCopySpan aSingleSpan = longClickCopySpan[0];
|
||||
if (action == MotionEvent.ACTION_DOWN) {
|
||||
Selection.setSelection(buffer, buffer.getSpanStart(aSingleSpan),
|
||||
buffer.getSpanEnd(aSingleSpan));
|
||||
aSingleSpan.setHighlighted(true,
|
||||
ContextCompat.getColor(widget.getContext(), R.color.touch_highlight));
|
||||
Selection.setSelection(buffer, buffer.getSpanStart(aSingleSpan), buffer.getSpanEnd(aSingleSpan));
|
||||
aSingleSpan.setHighlighted(true, ContextCompat.getColor(widget.getContext(), R.color.touch_highlight));
|
||||
} else {
|
||||
Selection.removeSelection(buffer);
|
||||
aSingleSpan.setHighlighted(false, Color.TRANSPARENT);
|
||||
|
@ -89,8 +106,7 @@ public class LongClickMovementMethod extends LinkMovementMethod {
|
|||
}
|
||||
} else if (action == MotionEvent.ACTION_CANCEL) {
|
||||
// Remove Selections.
|
||||
LongClickCopySpan[] spans = buffer.getSpans(Selection.getSelectionStart(buffer),
|
||||
Selection.getSelectionEnd(buffer), LongClickCopySpan.class);
|
||||
LongClickCopySpan[] spans = buffer.getSpans(Selection.getSelectionStart(buffer), Selection.getSelectionEnd(buffer), LongClickCopySpan.class);
|
||||
for (LongClickCopySpan aSpan : spans) {
|
||||
aSpan.setHighlighted(false, Color.TRANSPARENT);
|
||||
}
|
||||
|
|
|
@ -1,75 +0,0 @@
|
|||
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
|
||||
|
||||
fun interface InOnDrawProvider {
|
||||
fun isInOnDraw(): Boolean
|
||||
}
|
||||
}
|
|
@ -16,6 +16,7 @@
|
|||
<color name="signal_colorOnSecondaryContainer">@color/signal_dark_colorOnSecondaryContainer</color>
|
||||
<color name="signal_colorOnSurface">@color/signal_dark_colorOnSurface</color>
|
||||
<color name="signal_colorOnSurfaceVariant">@color/signal_dark_colorOnSurfaceVariant</color>
|
||||
<color name="signal_colorOnSurfaceVariant1">@color/signal_dark_colorOnSurfaceVariant1</color>
|
||||
<color name="signal_colorOnBackground">@color/signal_dark_colorOnBackground</color>
|
||||
<color name="signal_colorOutline">@color/signal_dark_colorOutline</color>
|
||||
<color name="signal_neutralSurface">@color/signal_dark_neutralSurface</color>
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
<color name="signal_colorOnSecondaryContainer">@color/signal_light_colorOnSecondaryContainer</color>
|
||||
<color name="signal_colorOnSurface">@color/signal_light_colorOnSurface</color>
|
||||
<color name="signal_colorOnSurfaceVariant">@color/signal_light_colorOnSurfaceVariant</color>
|
||||
<color name="signal_colorOnSurfaceVariant1">@color/signal_light_colorOnSurfaceVariant1</color>
|
||||
<color name="signal_colorOnBackground">@color/signal_light_colorOnBackground</color>
|
||||
<color name="signal_colorOutline">@color/signal_light_colorOutline</color>
|
||||
<color name="signal_neutralSurface">@color/signal_light_neutralSurface</color>
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
<color name="signal_dark_colorOnSecondaryContainer">#DCE1F9</color>
|
||||
<color name="signal_dark_colorOnSurface">#E2E1E5</color>
|
||||
<color name="signal_dark_colorOnSurfaceVariant">#BEBFC5</color>
|
||||
<color name="signal_dark_colorOnSurfaceVariant1">#4D5059</color>
|
||||
<color name="signal_dark_colorOnBackground">#E2E1E5</color>
|
||||
<color name="signal_dark_colorOutline">#5C5E65</color>
|
||||
<color name="signal_dark_neutralSurface">#14FFFFFF</color>
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
<color name="signal_light_colorOnSecondaryContainer">#151D2C</color>
|
||||
<color name="signal_light_colorOnSurface">#1B1B1D</color>
|
||||
<color name="signal_light_colorOnSurfaceVariant">#545863</color>
|
||||
<color name="signal_light_colorOnSurfaceVariant1">#BBBFC8</color>
|
||||
<color name="signal_light_colorOnBackground">#1B1D1D</color>
|
||||
<color name="signal_light_colorOutline">#808389</color>
|
||||
<color name="signal_light_neutralSurface">#99FFFFFF</color>
|
||||
|
|
|
@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.database.LocalMetricsDatabase
|
|||
import org.thoughtcrime.securesms.database.LogDatabase
|
||||
import org.thoughtcrime.securesms.database.MegaphoneDatabase
|
||||
import org.thoughtcrime.securesms.database.MessageBitmaskColumnTransformer
|
||||
import org.thoughtcrime.securesms.database.MessageRangesTransformer
|
||||
import org.thoughtcrime.securesms.database.ProfileKeyCredentialTransformer
|
||||
import org.thoughtcrime.securesms.database.QueryMonitor
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
|
@ -50,7 +51,7 @@ class SpinnerApplicationContext : ApplicationContext() {
|
|||
linkedMapOf(
|
||||
"signal" to DatabaseConfig(
|
||||
db = { SignalDatabase.rawDatabase },
|
||||
columnTransformers = listOf(MessageBitmaskColumnTransformer, GV2Transformer, GV2UpdateTransformer, IsStoryTransformer, TimestampTransformer, ProfileKeyCredentialTransformer)
|
||||
columnTransformers = listOf(MessageBitmaskColumnTransformer, GV2Transformer, GV2UpdateTransformer, IsStoryTransformer, TimestampTransformer, ProfileKeyCredentialTransformer, MessageRangesTransformer)
|
||||
),
|
||||
"jobmanager" to DatabaseConfig(db = { JobDatabase.getInstance(this).sqlCipherDatabase }),
|
||||
"keyvalue" to DatabaseConfig(db = { KeyValueDatabase.getInstance(this).sqlCipherDatabase }),
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.database.Cursor
|
||||
import org.signal.core.util.requireBlob
|
||||
import org.signal.spinner.ColumnTransformer
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
|
||||
object MessageRangesTransformer : ColumnTransformer {
|
||||
override fun matches(tableName: String?, columnName: String): Boolean {
|
||||
return columnName == MessageTable.MESSAGE_RANGES && (tableName == null || tableName == MessageTable.TABLE_NAME)
|
||||
}
|
||||
|
||||
override fun transform(tableName: String?, columnName: String, cursor: Cursor): String? {
|
||||
val messageRangesData: ByteArray? = cursor.requireBlob(MessageTable.MESSAGE_RANGES)
|
||||
|
||||
return if (messageRangesData != null) {
|
||||
val ranges = BodyRangeList.parseFrom(messageRangesData)
|
||||
ranges.rangesList
|
||||
.map { range ->
|
||||
val mention = range.hasMentionUuid()
|
||||
val style = range.hasStyle()
|
||||
val start = range.start
|
||||
val length = range.length
|
||||
|
||||
var rangeString = "<br>Type: ${if (mention) "mention" else "style"}<br>-start: $start<br>-length: $length"
|
||||
|
||||
if (mention) {
|
||||
rangeString += "<br>-uuid: ${range.mentionUuid}"
|
||||
}
|
||||
|
||||
if (style) {
|
||||
rangeString += "<br>-style: ${range.style}"
|
||||
}
|
||||
|
||||
rangeString
|
||||
}.joinToString("<br>")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue