Add spoiler format style.
This commit is contained in:
parent
5d6889786c
commit
43cd647036
20 changed files with 662 additions and 57 deletions
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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?) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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"
|
||||
*/
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 -->
|
||||
|
|
Loading…
Add table
Reference in a new issue