Add spoiler format style.

This commit is contained in:
Cody Henthorne 2023-03-22 16:42:30 -04:00
parent 5d6889786c
commit 43cd647036
20 changed files with 662 additions and 57 deletions

View file

@ -14,7 +14,6 @@ import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.TextUtils.TruncateAt;
import android.text.style.CharacterStyle;
import android.text.style.RelativeSizeSpan;
import android.util.AttributeSet;
import android.view.ActionMode;
@ -38,6 +37,7 @@ import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.mention.MentionDeleter;
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
import org.thoughtcrime.securesms.components.mention.MentionValidatorWatcher;
import org.thoughtcrime.securesms.components.spoiler.SpoilerRendererDelegate;
import org.thoughtcrime.securesms.conversation.MessageSendType;
import org.thoughtcrime.securesms.conversation.MessageStyler;
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQuery;
@ -65,6 +65,7 @@ public class ComposeText extends EmojiEditText {
private CharSequence hint;
private SpannableString subHint;
private MentionRendererDelegate mentionRendererDelegate;
private SpoilerRendererDelegate spoilerRendererDelegate;
private MentionValidatorWatcher mentionValidatorWatcher;
@Nullable private InputPanel.MediaListener mediaListener;
@ -152,6 +153,9 @@ public class ComposeText extends EmojiEditText {
try {
mentionRendererDelegate.draw(canvas, getText(), getLayout());
if (spoilerRendererDelegate != null) {
spoilerRendererDelegate.draw(canvas, getText(), getLayout());
}
} finally {
canvas.restoreToCount(checkpoint);
}
@ -298,6 +302,10 @@ public class ComposeText extends EmojiEditText {
addTextChangedListener(mentionValidatorWatcher);
if (FeatureFlags.textFormatting()) {
if (FeatureFlags.textFormattingSpoilerSend()) {
spoilerRendererDelegate = new SpoilerRendererDelegate(this, true);
}
addTextChangedListener(new ComposeTextStyleWatcher());
setCustomSelectionActionModeCallback(new ActionMode.Callback() {
@ -316,6 +324,10 @@ public class ComposeText extends EmojiEditText {
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));
}
return true;
}
@ -330,7 +342,8 @@ public class ComposeText extends EmojiEditText {
if (item.getItemId() != R.id.edittext_bold &&
item.getItemId() != R.id.edittext_italic &&
item.getItemId() != R.id.edittext_strikethrough &&
item.getItemId() != R.id.edittext_monospace) {
item.getItemId() != R.id.edittext_monospace &&
item.getItemId() != R.id.edittext_spoiler) {
return false;
}
@ -339,7 +352,7 @@ public class ComposeText extends EmojiEditText {
CharSequence charSequence = text.subSequence(start, end);
SpannableString replacement = new SpannableString(charSequence);
CharacterStyle style = null;
Object style = null;
if (item.getItemId() == R.id.edittext_bold) {
style = MessageStyler.boldStyle();
@ -349,6 +362,8 @@ public class ComposeText extends EmojiEditText {
style = MessageStyler.strikethroughStyle();
} 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);
}
if (style != null) {

View file

@ -6,9 +6,9 @@ import android.text.Spannable
import android.text.Spanned
import android.text.TextUtils
import android.text.TextWatcher
import android.text.style.CharacterStyle
import org.signal.core.util.StringUtil
import org.thoughtcrime.securesms.conversation.MessageStyler
import org.thoughtcrime.securesms.conversation.MessageStyler.isSupportedStyle
/**
* Formatting should only grow when appending until a white space character is entered/pasted.
@ -44,37 +44,49 @@ class ComposeTextStyleWatcher : TextWatcher {
s.removeSpan(markerAnnotation)
if (editStart < 0 || editEnd < 0 || editStart >= editEnd || (editStart == 0 && editEnd == s.length)) {
return
}
val change = s.subSequence(editStart, editEnd)
if (change.isEmpty() || textSnapshotPriorToChange == null || (editEnd - editStart == 1 && !StringUtil.isVisuallyEmpty(change[0])) || TextUtils.equals(textSnapshotPriorToChange, change)) {
textSnapshotPriorToChange = null
return
}
textSnapshotPriorToChange = null
var newEnd = editStart
for (i in change.indices) {
if (StringUtil.isVisuallyEmpty(change[i])) {
newEnd = editStart + i
break
try {
if (editStart < 0 || editEnd < 0 || editStart >= editEnd || (editStart == 0 && editEnd == s.length)) {
return
}
}
s.getSpans(editStart, editEnd, CharacterStyle::class.java)
.filter { MessageStyler.isSupportedCharacterStyle(it) }
.forEach { style ->
val styleStart = s.getSpanStart(style)
val styleEnd = s.getSpanEnd(style)
val change = s.subSequence(editStart, editEnd)
if (change.isEmpty() || textSnapshotPriorToChange == null || (editEnd - editStart == 1 && !StringUtil.isVisuallyEmpty(change[0])) || TextUtils.equals(textSnapshotPriorToChange, change)) {
textSnapshotPriorToChange = null
return
}
textSnapshotPriorToChange = null
if (styleEnd == editEnd && styleStart < styleEnd) {
s.removeSpan(style)
s.setSpan(style, styleStart, newEnd, MessageStyler.SPAN_FLAGS)
} else {
s.removeSpan(style)
var newEnd = editStart
for (i in change.indices) {
if (StringUtil.isVisuallyEmpty(change[i])) {
newEnd = editStart + i
break
}
}
s.getSpans(editStart, editEnd, Object::class.java)
.filter { it.isSupportedStyle() }
.forEach { style ->
val styleStart = s.getSpanStart(style)
val styleEnd = s.getSpanEnd(style)
if (styleEnd == editEnd && styleStart < styleEnd) {
s.removeSpan(style)
s.setSpan(style, styleStart, newEnd, MessageStyler.SPAN_FLAGS)
} else if (styleStart >= styleEnd) {
s.removeSpan(style)
}
}
} finally {
s.getSpans(editStart, editEnd, Object::class.java)
.filter { it.isSupportedStyle() }
.forEach { style ->
val styleStart = s.getSpanStart(style)
val styleEnd = s.getSpanEnd(style)
if (styleEnd == styleStart || styleStart > styleEnd) {
s.removeSpan(style)
}
}
}
}
}

View file

@ -26,11 +26,13 @@ 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;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
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.Util;
@ -66,7 +68,8 @@ public class EmojiTextView extends AppCompatTextView {
private boolean isJumbomoji;
private boolean forceJumboEmoji;
private MentionRendererDelegate mentionRendererDelegate;
private MentionRendererDelegate mentionRendererDelegate;
private final SpoilerRendererDelegate spoilerRendererDelegate;
public EmojiTextView(Context context) {
this(context, null);
@ -95,6 +98,7 @@ public class EmojiTextView extends AppCompatTextView {
if (renderMentions) {
mentionRendererDelegate = new MentionRendererDelegate(getContext(), ContextCompat.getColor(getContext(), R.color.transparent_black_20));
}
spoilerRendererDelegate = new SpoilerRendererDelegate(this);
textDirection = getLayoutDirection() == LAYOUT_DIRECTION_LTR ? TextDirectionHeuristics.FIRSTSTRONG_RTL : TextDirectionHeuristics.ANYRTL_LTR;
@ -103,11 +107,14 @@ public class EmojiTextView extends AppCompatTextView {
@Override
protected void onDraw(Canvas canvas) {
if (renderMentions && getText() instanceof Spanned && getLayout() != null) {
if (getText() instanceof Spanned && getLayout() != null) {
int checkpoint = canvas.save();
canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
try {
mentionRendererDelegate.draw(canvas, (Spanned) getText(), getLayout());
if (renderMentions) {
mentionRendererDelegate.draw(canvas, (Spanned) getText(), getLayout());
}
spoilerRendererDelegate.draw(canvas, (Spanned) getText(), getLayout());
} finally {
canvas.restoreToCount(checkpoint);
}
@ -381,6 +388,12 @@ public class EmojiTextView extends AppCompatTextView {
else super.invalidateDrawable(drawable);
}
@Override
public void setTextColor(int color) {
super.setTextColor(color);
spoilerRendererDelegate.updateFromTextColor();
}
@Override
public void setTextSize(float size) {
setTextSize(TypedValue.COMPLEX_UNIT_SP, size);

View file

@ -1,9 +1,12 @@
package org.thoughtcrime.securesms.components.emoji
import android.content.Context
import android.graphics.Canvas
import android.text.Spanned
import android.text.TextUtils
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import org.thoughtcrime.securesms.components.spoiler.SpoilerRendererDelegate
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.ThrottledDebouncer
import java.util.Optional
@ -16,9 +19,24 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
private var bufferType: BufferType? = null
private val sizeChangeDebouncer: ThrottledDebouncer = ThrottledDebouncer(200)
private val spoilerRendererDelegate: SpoilerRendererDelegate
init {
isEmojiCompatEnabled = isInEditMode || SignalStore.settings().isPreferSystemEmoji
spoilerRendererDelegate = SpoilerRendererDelegate(this)
}
override fun onDraw(canvas: Canvas) {
if (text is Spanned && layout != null) {
val checkpoint = canvas.save()
canvas.translate(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat())
try {
spoilerRendererDelegate.draw(canvas, (text as Spanned), layout)
} finally {
canvas.restoreToCount(checkpoint)
}
}
super.onDraw(canvas)
}
override fun setText(text: CharSequence?, type: BufferType?) {

View file

@ -0,0 +1,80 @@
package org.thoughtcrime.securesms.components.spoiler
import android.graphics.Color
import android.text.Annotation
import android.text.Spanned
import android.text.TextPaint
import android.text.style.ClickableSpan
import android.view.View
/**
* Helper for applying spans to text that should be rendered as a spoiler. Also
* tracks spoilers that have been revealed or not.
*/
object SpoilerAnnotation {
private const val SPOILER_ANNOTATION = "spoiler"
private val revealedSpoilers = mutableSetOf<String>()
@JvmStatic
fun spoilerAnnotation(hash: Int): Annotation {
return Annotation(SPOILER_ANNOTATION, hash.toString())
}
@JvmStatic
fun isSpoilerAnnotation(annotation: Annotation): Boolean {
return SPOILER_ANNOTATION == annotation.key
}
@JvmStatic
fun getSpoilerAnnotations(spanned: Spanned): List<Annotation> {
val spoilerAnnotations: Map<Pair<Int, Int>, Annotation> = spanned.getSpans(0, spanned.length, 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)
.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
}
}
}
@JvmStatic
fun getSpoilerAnnotations(spanned: Spanned, start: Int, end: Int): List<Annotation> {
return spanned
.getSpans(start, end, Annotation::class.java)
.filter { isSpoilerAnnotation(it) }
}
@JvmStatic
fun resetRevealedSpoilers() {
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)
}
override fun onClick(widget: View) {
spoilerRevealed = true
}
override fun updateDrawState(ds: TextPaint) {
if (!spoilerRevealed) {
ds.color = Color.TRANSPARENT
}
}
}
}

View file

@ -0,0 +1,148 @@
package org.thoughtcrime.securesms.components.spoiler
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.Rect
import android.graphics.drawable.Drawable
import androidx.annotation.ColorInt
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.Util
import kotlin.random.Random
/**
* Drawable that animates a sparkle effect for spoilers.
*/
class SpoilerDrawable(@ColorInt color: Int) : Drawable() {
private val alphaStrength = arrayOf(0.9f, 0.7f, 0.5f)
private val paints = listOf(Paint(), Paint(), Paint())
private var lastDrawTime: Long = 0
private var particleCount = 60
private var allParticles = Array(3) { Array(particleCount) { Particle(random) } }
private var allPoints = Array(3) { FloatArray(particleCount * 2) { 0f } }
init {
for (paint in paints) {
paint.strokeCap = Paint.Cap.ROUND
paint.strokeWidth = DimensionUnit.DP.toPixels(1.5f)
}
alpha = 255
colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
}
override fun onBoundsChange(bounds: Rect) {
val pixelArea = (bounds.right - bounds.left) * (bounds.bottom - bounds.top)
val newParticleCount = (pixelArea.toFloat() * PARTICLES_PER_PIXEL).toInt()
if (newParticleCount != particleCount) {
if (newParticleCount > allParticles[0].size) {
allParticles = Array(3) { i ->
Array(newParticleCount) { particleIndex ->
allParticles[i].getOrNull(particleIndex) ?: Particle(random)
}
}
allPoints = Array(3) { i ->
FloatArray(newParticleCount * 2) { pointIndex ->
allPoints[i].getOrNull(pointIndex) ?: 0f
}
}
}
particleCount = newParticleCount
}
}
override fun draw(canvas: Canvas) {
val left = bounds.left
val top = bounds.top
val right = bounds.right
val bottom = bounds.bottom
val now = System.currentTimeMillis()
val dt = now - lastDrawTime
lastDrawTime = now
for (allIndex in allParticles.indices) {
val particles = allParticles[allIndex]
for (index in 0 until particleCount) {
val particle = particles[index]
particle.timeRemaining = particle.timeRemaining - dt
if (particle.timeRemaining < 0 || !bounds.contains(particle.x.toInt(), particle.y.toInt())) {
particle.x = (random.nextFloat() * (right - left)) + left
particle.y = (random.nextFloat() * (bottom - top)) + top
particle.xVel = nextDirection()
particle.yVel = nextDirection()
particle.timeRemaining = 350 + 750 * random.nextFloat()
} else {
val change = dt * velocity
particle.x += particle.xVel * change
particle.y += particle.yVel * change
}
allPoints[allIndex][index * 2] = particle.x
allPoints[allIndex][index * 2 + 1] = particle.y
}
}
canvas.drawPoints(allPoints[0], 0, particleCount * 2, paints[0])
canvas.drawPoints(allPoints[1], 0, particleCount * 2, paints[1])
canvas.drawPoints(allPoints[2], 0, particleCount * 2, paints[2])
}
override fun setAlpha(alpha: Int) {
paints.forEachIndexed { index, paint ->
paint.alpha = (alpha * alphaStrength[index]).toInt()
}
}
override fun setColorFilter(colorFilter: ColorFilter?) {
for (paint in paints) {
paint.colorFilter = colorFilter
}
}
@Deprecated("Deprecated in Java", ReplaceWith("PixelFormat.TRANSPARENT", "android.graphics.PixelFormat"))
override fun getOpacity(): Int {
return PixelFormat.TRANSPARENT
}
data class Particle(
var x: Float,
var y: Float,
var xVel: Float,
var yVel: Float,
var timeRemaining: Float
) {
constructor(random: Random) : this(
-1f,
-1f,
if (random.nextFloat() < 0.5f) 1f else -1f,
if (random.nextFloat() < 0.5f) 1f else -1f,
500 + 1000 * random.nextFloat()
)
}
companion object {
private val PARTICLES_PER_PIXEL = if (Util.isLowMemory(ApplicationDependencies.getApplication())) 0.002f else 0.005f
private val velocity: Float = DimensionUnit.DP.toPixels(16f) / 1000f
private val random = Random(System.currentTimeMillis())
fun nextDirection(): Float {
val rand = random.nextFloat()
return if (rand < 0.5f) {
0.1f + 0.9f * rand
} else {
-0.1f - 0.9f * (rand - 0.5f)
}
}
}
}

View file

@ -0,0 +1,121 @@
package org.thoughtcrime.securesms.components.spoiler
import android.graphics.Canvas
import android.text.Layout
import org.thoughtcrime.securesms.util.LayoutUtil
import kotlin.math.max
import kotlin.math.min
/**
* Handles drawing the spoiler sparkles for a TextView.
*/
abstract class SpoilerRenderer {
abstract fun draw(
canvas: Canvas,
layout: Layout,
startLine: Int,
endLine: Int,
startOffset: Int,
endOffset: Int,
spoilerDrawables: List<SpoilerDrawable>
)
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 : 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,
spoilerDrawables: List<SpoilerDrawable>
) {
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)
spoilerDrawables[0].setBounds(left, lineTop, right, lineBottom)
spoilerDrawables[0].draw(canvas)
}
}
class MultiLineSpoilerRenderer : 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,
spoilerDrawables: List<SpoilerDrawable>
) {
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, spoilerDrawables)
if (startLine + 1 < endLine) {
var left = Int.MAX_VALUE
var right = -1
lineTop = Int.MAX_VALUE
lineBottom = -1
for (line in startLine + 1 until endLine) {
left = min(left, layout.getLineLeft(line).toInt())
right = max(right, layout.getLineRight(line).toInt())
lineTop = min(lineTop, lineTopCache.get(line, layout) { getLineTop(layout, line) })
lineBottom = max(lineBottom, lineBottomCache.get(line, layout) { getLineBottom(layout, line) })
}
spoilerDrawables[1].setBounds(left, lineTop, right, lineBottom)
spoilerDrawables[1].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, spoilerDrawables)
}
private fun drawStart(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int, spoilerDrawables: List<SpoilerDrawable>) {
if (start > end) {
spoilerDrawables[2].setBounds(end, top, start, bottom)
spoilerDrawables[2].draw(canvas)
} else {
spoilerDrawables[0].setBounds(start, top, end, bottom)
spoilerDrawables[0].draw(canvas)
}
}
private fun drawEnd(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int, spoilerDrawables: List<SpoilerDrawable>) {
if (start > end) {
spoilerDrawables[0].setBounds(end, top, start, bottom)
spoilerDrawables[0].draw(canvas)
} else {
spoilerDrawables[2].setBounds(start, top, end, bottom)
spoilerDrawables[2].draw(canvas)
}
}
}
}

View file

@ -0,0 +1,116 @@
package org.thoughtcrime.securesms.components.spoiler
import android.animation.ValueAnimator
import android.graphics.Canvas
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.text.Annotation
import android.text.Layout
import android.text.Spanned
import android.view.animation.LinearInterpolator
import android.widget.TextView
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.
*/
class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextView, private val renderForComposing: Boolean = false) {
private val single: SpoilerRenderer
private val multi: SpoilerRenderer
private var animatorRunning = false
private var textColor: Int
private var spoilerDrawablePool = mutableMapOf<Annotation, List<SpoilerDrawable>>()
private var nextSpoilerDrawablePool = mutableMapOf<Annotation, List<SpoilerDrawable>>()
private val cachedAnnotations = HashMap<Int, List<Annotation>>()
private val cachedMeasurements = HashMap<Int, SpanMeasurements>()
private val animator = ValueAnimator.ofInt(0, 100).apply {
duration = 1000
interpolator = LinearInterpolator()
addUpdateListener { view.invalidate() }
repeatCount = ValueAnimator.INFINITE
repeatMode = ValueAnimator.REVERSE
}
init {
single = SingleLineSpoilerRenderer()
multi = MultiLineSpoilerRenderer()
textColor = view.textColors.defaultColor
}
fun updateFromTextColor() {
val color = view.textColors.defaultColor
if (color != textColor) {
spoilerDrawablePool
.values
.flatten()
.forEach { it.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) }
textColor = color
}
}
fun draw(canvas: Canvas, text: Spanned, layout: Layout) {
var hasSpoilersToRender = false
val annotations: List<Annotation> = cachedAnnotations.getFromCache(text) { SpoilerAnnotation.getSpoilerAnnotations(text) }
nextSpoilerDrawablePool.clear()
for (annotation in annotations) {
val spanStart: Int = text.getSpanStart(annotation)
val spanEnd: Int = text.getSpanEnd(annotation)
if (spanStart >= spanEnd) {
continue
}
val measurements = cachedMeasurements.getFromCache(annotation.value, layout) {
val startLine = layout.getLineForOffset(spanStart)
val endLine = layout.getLineForOffset(spanEnd)
SpanMeasurements(
startLine = startLine,
endLine = endLine,
startOffset = (layout.getPrimaryHorizontal(spanStart) + -1 * layout.getParagraphDirection(startLine)).toInt(),
endOffset = (layout.getPrimaryHorizontal(spanEnd) + layout.getParagraphDirection(endLine)).toInt()
)
}
val renderer: SpoilerRenderer = if (measurements.startLine == measurements.endLine) single else multi
val drawables: List<SpoilerDrawable> = spoilerDrawablePool[annotation] ?: listOf(SpoilerDrawable(textColor), SpoilerDrawable(textColor), SpoilerDrawable(textColor))
renderer.draw(canvas, layout, measurements.startLine, measurements.endLine, measurements.startOffset, measurements.endOffset, drawables)
nextSpoilerDrawablePool[annotation] = drawables
hasSpoilersToRender = true
}
val temporaryPool = spoilerDrawablePool
spoilerDrawablePool = nextSpoilerDrawablePool
nextSpoilerDrawablePool = temporaryPool
if (hasSpoilersToRender) {
if (!animatorRunning) {
animator.start()
animatorRunning = true
}
} else {
animator.pause()
animatorRunning = false
}
}
private inline fun <V> MutableMap<Int, V>.getFromCache(vararg keys: Any, default: () -> V): V {
if (renderForComposing) {
return default()
}
return getOrPut(keys.contentHashCode(), default)
}
private data class SpanMeasurements(
val startLine: Int,
val endLine: Int,
val startOffset: Int,
val endOffset: Int
)
}

View file

@ -143,6 +143,7 @@ import org.thoughtcrime.securesms.components.reminder.ReminderView;
import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder;
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity;
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
@ -493,6 +494,7 @@ public class ConversationParentFragment extends Fragment
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
disposables.bindTo(getViewLifecycleOwner());
menuProvider = new ConversationOptionsMenu.Provider(this, this, disposables);
SpoilerAnnotation.resetRevealedSpoilers();
if (requireActivity() instanceof Callback) {
callback = (Callback) requireActivity();

View file

@ -1,12 +1,14 @@
package org.thoughtcrime.securesms.conversation
import android.graphics.Typeface
import android.text.Annotation
import android.text.Spannable
import android.text.Spanned
import android.text.style.CharacterStyle
import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan
import android.text.style.TypefaceSpan
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.util.PlaceholderURLSpan
@ -39,7 +41,13 @@ object MessageStyler {
}
@JvmStatic
fun style(messageRanges: BodyRangeList?, span: Spannable): Result {
fun spoilerStyle(start: Int, length: Int, body: Spannable? = null): Annotation {
return SpoilerAnnotation.spoilerAnnotation(arrayOf(start, length, body?.toString()).contentHashCode())
}
@JvmStatic
@JvmOverloads
fun style(messageRanges: BodyRangeList?, span: Spannable, hideSpoilerText: Boolean = true): Result {
if (messageRanges == null) {
return Result.none()
}
@ -53,11 +61,18 @@ object MessageStyler {
.filter { r -> r.start >= 0 && r.start < span.length && r.start + r.length >= 0 && r.start + r.length <= span.length }
.forEach { range ->
if (range.hasStyle()) {
val styleSpan: CharacterStyle? = when (range.style) {
val styleSpan: Any? = when (range.style) {
BodyRangeList.BodyRange.Style.BOLD -> boldStyle()
BodyRangeList.BodyRange.Style.ITALIC -> italicStyle()
BodyRangeList.BodyRange.Style.STRIKETHROUGH -> strikethroughStyle()
BodyRangeList.BodyRange.Style.MONOSPACE -> monoStyle()
BodyRangeList.BodyRange.Style.SPOILER -> {
val spoiler = spoilerStyle(range.start, range.length, span)
if (hideSpoilerText) {
span.setSpan(SpoilerAnnotation.SpoilerClickableSpan(spoiler), range.start, range.start + range.length, SPAN_FLAGS)
}
spoiler
}
else -> null
}
@ -83,34 +98,46 @@ object MessageStyler {
@JvmStatic
fun hasStyling(text: Spanned): Boolean {
return text
.getSpans(0, text.length, CharacterStyle::class.java)
.any { s -> isSupportedCharacterStyle(s) && text.getSpanEnd(s) - text.getSpanStart(s) > 0 }
.getSpans(0, text.length, Object::class.java)
.any { s -> s.isSupportedStyle() && text.getSpanEnd(s) - text.getSpanStart(s) > 0 }
}
@JvmStatic
fun getStyling(text: CharSequence?): BodyRangeList? {
val bodyRanges = if (text is Spanned) {
text
.getSpans(0, text.length, CharacterStyle::class.java)
.filter { s -> isSupportedCharacterStyle(s) }
.mapNotNull { span: CharacterStyle ->
.getSpans(0, text.length, Object::class.java)
.filter { s -> s.isSupportedStyle() }
.mapNotNull { span ->
val spanStart = text.getSpanStart(span)
val spanLength = text.getSpanEnd(span) - spanStart
val style = when (span) {
is StyleSpan -> if (span.style == Typeface.BOLD) BodyRangeList.BodyRange.Style.BOLD else BodyRangeList.BodyRange.Style.ITALIC
val style: BodyRangeList.BodyRange.Style? = when (span) {
is StyleSpan -> {
when (span.style) {
Typeface.BOLD -> BodyRangeList.BodyRange.Style.BOLD
Typeface.ITALIC -> BodyRangeList.BodyRange.Style.ITALIC
else -> null
}
}
is StrikethroughSpan -> BodyRangeList.BodyRange.Style.STRIKETHROUGH
is TypefaceSpan -> BodyRangeList.BodyRange.Style.MONOSPACE
is Annotation -> {
if (SpoilerAnnotation.isSpoilerAnnotation(span)) {
BodyRangeList.BodyRange.Style.SPOILER
} else {
null
}
}
else -> throw IllegalArgumentException("Provided text contains unsupported spans")
}
if (spanLength > 0) {
if (spanLength > 0 && style != null) {
BodyRangeList.BodyRange.newBuilder().setStart(spanStart).setLength(spanLength).setStyle(style).build()
} else {
null
}
}
.toList()
} else {
emptyList()
}
@ -122,8 +149,15 @@ object MessageStyler {
}
}
@JvmStatic
fun isSupportedCharacterStyle(style: CharacterStyle): Boolean {
fun Any.isSupportedStyle(): Boolean {
return when (this) {
is CharacterStyle -> isSupportedCharacterStyle(this)
is Annotation -> SpoilerAnnotation.isSpoilerAnnotation(this)
else -> false
}
}
private fun isSupportedCharacterStyle(style: CharacterStyle): Boolean {
return when (style) {
is StyleSpan -> style.style == Typeface.ITALIC || style.style == Typeface.BOLD
is StrikethroughSpan -> true

View file

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

View file

@ -112,6 +112,7 @@ import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
import org.thoughtcrime.securesms.components.reminder.UsernameOutOfSyncReminder;
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation;
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter;
@ -474,6 +475,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
updateReminders();
EventBus.getDefault().register(this);
itemAnimator.disable();
SpoilerAnnotation.resetRevealedSpoilers();
if (Util.isDefaultSmsProvider(requireContext())) {
InsightsLauncher.showInsightsModal(requireContext(), requireFragmentManager());

View file

@ -32,8 +32,8 @@ public final class MentionUtil {
private MentionUtil() { }
@WorkerThread
public static @Nullable CharSequence updateBodyWithDisplayNames(@NonNull Context context, @NonNull MessageRecord messageRecord) {
return updateBodyWithDisplayNames(context, messageRecord, messageRecord.getDisplayBody(context)).getBody();
public static @NonNull UpdatedBodyAndMentions updateBodyWithDisplayNames(@NonNull Context context, @NonNull MessageRecord messageRecord) {
return updateBodyWithDisplayNames(context, messageRecord, messageRecord.getDisplayBody(context));
}
@WorkerThread

View file

@ -59,7 +59,7 @@ fun List<BodyRange>?.toBodyRangeList(): BodyRangeList? {
when (bodyRange.style) {
BodyRange.Style.BOLD -> style = BodyRangeList.BodyRange.Style.BOLD
BodyRange.Style.ITALIC -> style = BodyRangeList.BodyRange.Style.ITALIC
BodyRange.Style.SPOILER -> Unit
BodyRange.Style.SPOILER -> style = BodyRangeList.BodyRange.Style.SPOILER
BodyRange.Style.STRIKETHROUGH -> style = BodyRangeList.BodyRange.Style.STRIKETHROUGH
BodyRange.Style.MONOSPACE -> style = BodyRangeList.BodyRange.Style.MONOSPACE
else -> Unit

View file

@ -482,7 +482,7 @@ public abstract class PushSendJob extends SendJob {
builder.setStyle(SignalServiceProtos.BodyRange.Style.ITALIC);
break;
case SPOILER:
// Intentionally left blank
builder.setStyle(SignalServiceProtos.BodyRange.Style.SPOILER);
break;
case STRIKETHROUGH:
builder.setStyle(SignalServiceProtos.BodyRange.Style.STRIKETHROUGH);

View file

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.notifications.v2
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.TextUtils
import androidx.annotation.StringRes
@ -13,9 +14,11 @@ import org.thoughtcrime.securesms.contactshare.ContactUtil
import org.thoughtcrime.securesms.database.MentionUtil
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.ThreadBodyUtil
import org.thoughtcrime.securesms.database.adjustBodyRanges
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.mms.SlideDeck
@ -156,6 +159,29 @@ sealed class NotificationItem(val threadRecipient: Recipient, protected val reco
record.isRemoteDelete == other.record.isRemoteDelete
}
protected fun getBodyWithMentionsAndStyles(context: Context, record: MessageRecord): CharSequence {
val updated = MentionUtil.updateBodyWithDisplayNames(context, record)
var updatedText: CharSequence = SpannableString(updated.body ?: "")
val spoilerRanges: List<BodyRangeList.BodyRange>? = record
.messageRanges
.adjustBodyRanges(updated.bodyAdjustments)
?.run {
rangesList
.filter { it.style == BodyRangeList.BodyRange.Style.SPOILER }
.sortedBy { it.start }
.reversed()
}
if (spoilerRanges?.isNotEmpty() == true) {
for (spoiler in spoilerRanges) {
updatedText = updatedText.replaceRange(spoiler.start.coerceAtMost(updatedText.length - 1), (spoiler.start + spoiler.length).coerceAtMost(updatedText.length), "■■■■")
}
}
return updatedText
}
private fun CharSequence?.trimToDisplayLength(): CharSequence {
val text: CharSequence = this ?: ""
return if (text.length <= MAX_DISPLAY_LENGTH) {
@ -203,7 +229,7 @@ class MessageNotification(threadRecipient: Recipient, record: MessageRecord) : N
} else if (record.isPaymentNotification) {
ThreadBodyUtil.getFormattedBodyFor(context, record).body
} else {
MentionUtil.updateBodyWithDisplayNames(context, record) ?: ""
getBodyWithMentionsAndStyles(context, record)
}
}
@ -299,7 +325,7 @@ class ReactionNotification(threadRecipient: Recipient, record: MessageRecord, va
}
private fun getReactionMessageBody(context: Context): CharSequence {
val body: CharSequence = MentionUtil.updateBodyWithDisplayNames(context, record) ?: ""
val body: CharSequence = getBodyWithMentionsAndStyles(context, record)
val bodyIsEmpty: Boolean = TextUtils.isEmpty(body)
return if (record.hasSharedContact()) {

View file

@ -107,6 +107,7 @@ 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";
private static final String TEXT_FORMATTING_SPOILER_SEND = "android.textFormatting.spoilerSend";
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
@ -164,7 +165,8 @@ public final class FeatureFlags {
PAYPAL_RECURRING_DONATIONS,
TEXT_FORMATTING,
ANY_ADDRESS_PORTS_KILL_SWITCH,
CALLS_TAB
CALLS_TAB,
TEXT_FORMATTING_SPOILER_SEND
);
@VisibleForTesting
@ -227,7 +229,8 @@ public final class FeatureFlags {
CREDIT_CARD_PAYMENTS,
PAYMENTS_REQUEST_ACTIVATE_FLOW,
CDS_HARD_LIMIT,
TEXT_FORMATTING
TEXT_FORMATTING,
TEXT_FORMATTING_SPOILER_SEND
);
/**
@ -579,6 +582,13 @@ 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"
*/

View file

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.util;
import android.text.Annotation;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.TextUtils;
@ -12,6 +13,7 @@ import com.annimon.stream.Stream;
import org.signal.core.util.StringUtil;
import org.signal.libsignal.protocol.util.Pair;
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
import java.security.InvalidParameterException;
import java.util.Collections;
@ -71,7 +73,10 @@ public class SearchUtil {
CharacterStyle[] styles = styleFactory.createStyles();
for (Pair<Integer, Integer> range : ranges) {
for (CharacterStyle style : styles) {
spanned.setSpan(style, range.first(), range.second(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
List<Annotation> annotations = SpoilerAnnotation.getSpoilerAnnotations(spanned, range.first(), range.second());
if (annotations.isEmpty()) {
spanned.setSpan(style, range.first(), range.second(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
}
}
}

View file

@ -29,4 +29,5 @@
<item name="edittext_italic" type="id" />
<item name="edittext_strikethrough" type="id" />
<item name="edittext_monospace" type="id" />
<item name="edittext_spoiler" type="id" />
</resources>

View file

@ -5684,6 +5684,8 @@
<string name="TextFormatting_strikethrough">Strikethrough</string>
<!-- Popup menu label for applying monospace font style -->
<string name="TextFormatting_monospace">Monospace</string>
<!-- Popup menu label for applying spoiler style -->
<string name="TextFormatting_spoiler">Spoiler</string>
<!-- UsernameEducationFragment -->
<!-- Continue button which takes the user to the add a username screen -->