diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java index 57d5751d80..f59f99ea8b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeTextStyleWatcher.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeTextStyleWatcher.kt index d904c0b6ec..2de670ac40 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeTextStyleWatcher.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeTextStyleWatcher.kt @@ -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) + } + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java index d5fb7630ea..234d1287e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/SimpleEmojiTextView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/SimpleEmojiTextView.kt index 172cba0757..6ee69a1f85 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/SimpleEmojiTextView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/SimpleEmojiTextView.kt @@ -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?) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerAnnotation.kt b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerAnnotation.kt new file mode 100644 index 0000000000..ada272afc4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerAnnotation.kt @@ -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() + + @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 { + val spoilerAnnotations: Map, Annotation> = spanned.getSpans(0, spanned.length, Annotation::class.java) + .filter { isSpoilerAnnotation(it) } + .associateBy { (spanned.getSpanStart(it) to spanned.getSpanEnd(it)) } + + val spoilerClickSpans: Map, SpoilerClickableSpan> = spanned.getSpans(0, spanned.length, SpoilerClickableSpan::class.java) + .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 { + 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 + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerDrawable.kt b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerDrawable.kt new file mode 100644 index 0000000000..3f8412a84f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerDrawable.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerRenderer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerRenderer.kt new file mode 100644 index 0000000000..1117245238 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerRenderer.kt @@ -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 + ) + + 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.get(line: Int, layout: Layout, default: () -> Int): Int { + return getOrPut(line * 31 + layout.hashCode() * 31, default) + } + + class SingleLineSpoilerRenderer : SpoilerRenderer() { + private val lineTopCache = HashMap() + private val lineBottomCache = HashMap() + + override fun draw( + canvas: Canvas, + layout: Layout, + startLine: Int, + endLine: Int, + startOffset: Int, + endOffset: Int, + spoilerDrawables: List + ) { + 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() + private val lineBottomCache = HashMap() + + override fun draw( + canvas: Canvas, + layout: Layout, + startLine: Int, + endLine: Int, + startOffset: Int, + endOffset: Int, + spoilerDrawables: List + ) { + 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) { + 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) { + 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) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerRendererDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerRendererDelegate.kt new file mode 100644 index 0000000000..56bb4114fe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerRendererDelegate.kt @@ -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>() + private var nextSpoilerDrawablePool = mutableMapOf>() + + private val cachedAnnotations = HashMap>() + private val cachedMeasurements = HashMap() + + 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 = 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 = 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 MutableMap.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 + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java index bd916f0e9b..00a800224c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MessageStyler.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/MessageStyler.kt index a7e059b279..93f8061cc5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MessageStyler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MessageStyler.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt index 2e4c3fe8a2..c18d495074 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt @@ -79,7 +79,7 @@ class DraftRepository( updatedText = SpannableString(updated.body) MentionAnnotation.setMentionAnnotations(updatedText, updated.mentions) - MessageStyler.style(bodyRanges.adjustBodyRanges(updated.bodyAdjustments), updatedText) + MessageStyler.style(messageRanges = bodyRanges.adjustBodyRanges(updated.bodyAdjustments), span = updatedText, hideSpoilerText = false) } DatabaseDraft(drafts, updatedText) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 7ce4c6e7c3..daade19a72 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MentionUtil.java b/app/src/main/java/org/thoughtcrime/securesms/database/MentionUtil.java index 79e2d78948..cc267f7205 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MentionUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MentionUtil.java @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DatabaseProtosUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/DatabaseProtosUtil.kt index e93366b33b..53e4e95181 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DatabaseProtosUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DatabaseProtosUtil.kt @@ -59,7 +59,7 @@ fun List?.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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java index 9b0e96004f..e3ddd33154 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationItem.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationItem.kt index 1d28ab6609..b004db8bfc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationItem.kt @@ -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? = 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()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 715c78c046..4da4999c38 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -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" */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java index 4ef13a4a0e..ee2574c7e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java @@ -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 range : ranges) { for (CharacterStyle style : styles) { - spanned.setSpan(style, range.first(), range.second(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + List annotations = SpoilerAnnotation.getSpoilerAnnotations(spanned, range.first(), range.second()); + if (annotations.isEmpty()) { + spanned.setSpan(style, range.first(), range.second(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + } } } diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 82269d2177..c735047a1e 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -29,4 +29,5 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d28035cea6..ff6bb3f167 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5684,6 +5684,8 @@ Strikethrough Monospace + + Spoiler