From 7c9aa3de7263c511c80e7f7f6d8dbda4de3c8d2b Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Mon, 24 Nov 2025 10:49:00 -0400 Subject: [PATCH] Add support for displaying spoiler text in compose. --- .../components/spoiler/SpoilerPaint.kt | 15 +- .../spoiler/compose/SpoilerModifier.kt | 149 +++++++++ .../spoiler/compose/SpoilerState.kt | 46 +++ .../components/spoiler/compose/SpoilerText.kt | 213 +++++++++++++ .../spoiler/compose/SpoilerTextPreviews.kt | 289 ++++++++++++++++++ 5 files changed, 709 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/spoiler/compose/SpoilerModifier.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/spoiler/compose/SpoilerState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/spoiler/compose/SpoilerText.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/spoiler/compose/SpoilerTextPreviews.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerPaint.kt b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerPaint.kt index 2644868b07..14ad8ef659 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerPaint.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerPaint.kt @@ -28,9 +28,18 @@ object SpoilerPaint { */ var shader: BitmapShader? = null - private val WIDTH = if (Util.isLowMemory(AppDependencies.application)) 50.dp else 100.dp - private val HEIGHT = if (Util.isLowMemory(AppDependencies.application)) 20.dp else 40.dp - private val PARTICLES_PER_PIXEL = if (Util.isLowMemory(AppDependencies.application)) 0.001f else 0.002f + private val WIDTH = if (isLowMemory()) 50.dp else 100.dp + private val HEIGHT = if (isLowMemory()) 20.dp else 40.dp + private val PARTICLES_PER_PIXEL = if (isLowMemory()) 0.001f else 0.002f + + private fun isLowMemory(): Boolean { + return try { + Util.isLowMemory(AppDependencies.application) + } catch (e: Throwable) { + // In preview mode or when AppDependencies is not initialized, default to low memory mode + true + } + } private var shaderBitmap: Bitmap = Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ALPHA_8) private var bufferBitmap: Bitmap = Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ALPHA_8) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/compose/SpoilerModifier.kt b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/compose/SpoilerModifier.kt new file mode 100644 index 0000000000..bb7b2c2eee --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/compose/SpoilerModifier.kt @@ -0,0 +1,149 @@ +package org.thoughtcrime.securesms.components.spoiler.compose + +import android.graphics.Paint +import android.graphics.PorterDuff +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import org.thoughtcrime.securesms.components.spoiler.SpoilerPaint + +/** + * Annotation tag used to mark spoiler text ranges in an AnnotatedString. + */ +const val SPOILER_ANNOTATION_TAG = "spoiler" + +/** + * Modifier that draws the spoiler effect over specific text regions. + * + * @param spoilerState State holder for revealed spoilers + * @param annotatedString The text with spoiler annotations + * @param textLayoutResult The result of text layout measurement + * @param textColor Color to tint the particles + */ +fun Modifier.drawSpoilers( + spoilerState: SpoilerState, + annotatedString: AnnotatedString, + textLayoutResult: TextLayoutResult?, + textColor: Color +): Modifier = this.then( + Modifier.drawWithCache { + val paint = Paint() + val colorFilter = android.graphics.PorterDuffColorFilter( + textColor.toArgb(), + PorterDuff.Mode.SRC_IN + ) + + onDrawWithContent { + drawContent() + + val layout = textLayoutResult ?: return@onDrawWithContent + + // Get all spoiler annotations + val spoilerAnnotations = annotatedString.getStringAnnotations( + tag = SPOILER_ANNOTATION_TAG, + start = 0, + end = annotatedString.length + ) + + if (spoilerAnnotations.isEmpty()) { + return@onDrawWithContent + } + + val shader = SpoilerPaint.shader + + drawIntoCanvas { canvas -> + val nativeCanvas = canvas.nativeCanvas + + // Update paint properties for this draw + if (shader != null) { + paint.shader = shader + paint.colorFilter = colorFilter + } else { + paint.shader = null + paint.color = android.graphics.Color.TRANSPARENT + } + + for (annotation in spoilerAnnotations) { + if (spoilerState.isRevealed(annotation.item)) { + continue + } + + val start = annotation.start + val end = annotation.end + + if (start >= end) { + continue + } + + val startLine = layout.getLineForOffset(start) + val endLine = layout.getLineForOffset(end) + + if (startLine == endLine) { + val left = layout.getHorizontalPosition(start, true) + val right = layout.getHorizontalPosition(end, true) + val top = layout.getLineTop(startLine) + val bottom = layout.getLineBottom(startLine) + + nativeCanvas.drawRect( + left.coerceAtMost(right), + top, + left.coerceAtLeast(right), + bottom, + paint + ) + } else { + val firstLineLeft = layout.getHorizontalPosition(start, true) + val firstLineRight = if (layout.getParagraphDirection(startLine) == androidx.compose.ui.text.style.ResolvedTextDirection.Rtl) { + layout.getLineLeft(startLine) + } else { + layout.getLineRight(startLine) + } + nativeCanvas.drawRect( + firstLineLeft.coerceAtMost(firstLineRight), + layout.getLineTop(startLine), + firstLineLeft.coerceAtLeast(firstLineRight), + layout.getLineBottom(startLine), + paint + ) + + for (line in startLine + 1 until endLine) { + nativeCanvas.drawRect( + layout.getLineLeft(line), + layout.getLineTop(line), + layout.getLineRight(line), + layout.getLineBottom(line), + paint + ) + } + + val lastLineLeft = if (layout.getParagraphDirection(endLine) == androidx.compose.ui.text.style.ResolvedTextDirection.Rtl) { + layout.getLineRight(endLine) + } else { + layout.getLineLeft(endLine) + } + val lastLineRight = layout.getHorizontalPosition(end, true) + nativeCanvas.drawRect( + lastLineLeft.coerceAtMost(lastLineRight), + layout.getLineTop(endLine), + lastLineLeft.coerceAtLeast(lastLineRight), + layout.getLineBottom(endLine), + paint + ) + } + } + } + } + } +) + +/** + * Find which spoiler annotation (if any) contains the given offset in the text. + */ +fun AnnotatedString.findSpoilerAt(offset: Int): AnnotatedString.Range? { + return getStringAnnotations(SPOILER_ANNOTATION_TAG, offset, offset).firstOrNull() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/compose/SpoilerState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/compose/SpoilerState.kt new file mode 100644 index 0000000000..8e6871a7a8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/compose/SpoilerState.kt @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.components.spoiler.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +/** + * State holder for managing which spoilers have been revealed in Compose. + * Similar to the View-based SpoilerAnnotation, but uses Compose state management. + */ +@Stable +class SpoilerState { + private var revealedSpoilers by mutableStateOf(setOf()) + + /** + * Check if a spoiler with the given ID has been revealed. + */ + fun isRevealed(spoilerId: String): Boolean { + return spoilerId in revealedSpoilers + } + + /** + * Reveal a spoiler with the given ID. + */ + fun reveal(spoilerId: String) { + revealedSpoilers = revealedSpoilers + spoilerId + } + + /** + * Reset all revealed spoilers. + */ + fun reset() { + revealedSpoilers = emptySet() + } +} + +/** + * Remember a [SpoilerState] instance. + */ +@Composable +fun rememberSpoilerState(): SpoilerState { + return remember { SpoilerState() } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/compose/SpoilerText.kt b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/compose/SpoilerText.kt new file mode 100644 index 0000000000..b3ae3ce63b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/compose/SpoilerText.kt @@ -0,0 +1,213 @@ +package org.thoughtcrime.securesms.components.spoiler.compose + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import org.thoughtcrime.securesms.components.spoiler.SpoilerPaint + +/** + * A Text composable that supports spoiler annotations with particle effects. + * + * Text ranges marked with [SPOILER_ANNOTATION_TAG] annotations will be rendered with + * an animated particle effect until revealed by tapping. + * + * Example usage: + * ``` + * val spoilerState = rememberSpoilerState() + * val text = buildAnnotatedString { + * append("This is normal text. ") + * withAnnotation(SPOILER_ANNOTATION_TAG, "spoiler-1") { + * append("This is a spoiler!") + * } + * append(" More normal text.") + * } + * SpoilerText( + * text = text, + * spoilerState = spoilerState + * ) + * ``` + * + * @param text The annotated string with optional spoiler annotations + * @param spoilerState State holder for managing revealed spoilers + * @param modifier Modifier to be applied to the Text + * @param color Color to apply to the text + * @param fontSize Font size to apply to the text + * @param textAlign Text alignment + * @param textDecoration Text decoration + * @param overflow How to handle text overflow + * @param softWrap Whether the text should break at soft line breaks + * @param maxLines Maximum number of lines + * @param minLines Minimum number of lines + * @param onTextLayout Callback for text layout results + * @param style Text style to apply + * @param inlineContent Map of inline content for the text + */ +@Composable +fun SpoilerText( + text: AnnotatedString, + spoilerState: SpoilerState, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + textAlign: TextAlign? = null, + textDecoration: TextDecoration? = null, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current, + inlineContent: Map = mapOf() +) { + var textLayoutResult by remember { mutableStateOf(null) } + var frameTime by remember { mutableLongStateOf(0L) } + + // Get all spoiler annotations + val spoilerAnnotations = remember(text) { + text.getStringAnnotations(SPOILER_ANNOTATION_TAG, 0, text.length) + } + + // Check if there are any unrevealed spoilers + val hasUnrevealedSpoilers = remember(spoilerAnnotations, spoilerState, frameTime) { + spoilerAnnotations.any { !spoilerState.isRevealed(it.item) } + } + + // Animate when there are unrevealed spoilers + LaunchedEffect(hasUnrevealedSpoilers) { + if (hasUnrevealedSpoilers) { + while (true) { + withFrameNanos { nanos -> + SpoilerPaint.update() + frameTime = nanos + } + } + } + } + + val originalTextColor = if (color != Color.Unspecified) { + color + } else if (style.color != Color.Unspecified) { + style.color + } else { + LocalContentColor.current + } + + val displayText = remember(text, spoilerAnnotations, spoilerState, frameTime) { + AnnotatedString.Builder(text).apply { + for (annotation in spoilerAnnotations) { + if (!spoilerState.isRevealed(annotation.item)) { + addStyle( + style = androidx.compose.ui.text.SpanStyle(color = Color.Transparent), + start = annotation.start, + end = annotation.end + ) + } + } + }.toAnnotatedString() + } + + Text( + text = displayText, + modifier = modifier + .drawSpoilers( + spoilerState = spoilerState, + annotatedString = text, + textLayoutResult = textLayoutResult, + textColor = originalTextColor + ) + .pointerInput(text, spoilerState) { + detectTapGestures { offset -> + val layout = textLayoutResult ?: return@detectTapGestures + val position = layout.getOffsetForPosition(offset) + val spoiler = text.findSpoilerAt(position) + if (spoiler != null && !spoilerState.isRevealed(spoiler.item)) { + spoilerState.reveal(spoiler.item) + } + } + }, + color = color, + fontSize = fontSize, + textAlign = textAlign, + textDecoration = textDecoration, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + onTextLayout = { result -> + textLayoutResult = result + onTextLayout(result) + }, + style = style, + inlineContent = inlineContent + ) +} + +/** + * A Text composable that supports spoiler annotations, using a simple string with builder. + * + * @param text The plain text to display + * @param spoilerState State holder for managing revealed spoilers + * @param modifier Modifier to be applied to the Text + * @param color Color to apply to the text + * @param fontSize Font size to apply to the text + * @param textAlign Text alignment + * @param textDecoration Text decoration + * @param overflow How to handle text overflow + * @param softWrap Whether the text should break at soft line breaks + * @param maxLines Maximum number of lines + * @param minLines Minimum number of lines + * @param onTextLayout Callback for text layout results + * @param style Text style to apply + */ +@Composable +fun SpoilerText( + text: String, + spoilerState: SpoilerState, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + textAlign: TextAlign? = null, + textDecoration: TextDecoration? = null, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current +) { + SpoilerText( + text = AnnotatedString(text), + spoilerState = spoilerState, + modifier = modifier, + color = color, + fontSize = fontSize, + textAlign = textAlign, + textDecoration = textDecoration, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + onTextLayout = onTextLayout, + style = style + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/compose/SpoilerTextPreviews.kt b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/compose/SpoilerTextPreviews.kt new file mode 100644 index 0000000000..67a8e7df47 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/compose/SpoilerTextPreviews.kt @@ -0,0 +1,289 @@ +package org.thoughtcrime.securesms.components.spoiler.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews + +@DayNightPreviews +@Composable +private fun SingleSpoilerPreview() { + val spoilerState = rememberSpoilerState() + val text = buildAnnotatedString { + append("This is some normal text and ") + val start = length + append("this part is hidden") + addStringAnnotation(SPOILER_ANNOTATION_TAG, "spoiler-1", start, length) + append(" until you tap it!") + } + + Previews.Preview { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + SpoilerText( + text = text, + spoilerState = spoilerState, + style = MaterialTheme.typography.bodyLarge + ) + } + } +} + +@DayNightPreviews +@Composable +private fun MultipleSpoilersPreview() { + val spoilerState = rememberSpoilerState() + val text = buildAnnotatedString { + append("The answer to question 1 is ") + val start1 = length + append("42") + addStringAnnotation(SPOILER_ANNOTATION_TAG, "answer-1", start1, length) + append(" and the answer to question 2 is ") + val start2 = length + append("the meaning of life") + addStringAnnotation(SPOILER_ANNOTATION_TAG, "answer-2", start2, length) + append(".") + } + + Previews.Preview { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + SpoilerText( + text = text, + spoilerState = spoilerState, + style = MaterialTheme.typography.bodyLarge + ) + } + } +} + +@DayNightPreviews +@Composable +private fun MultilineSpoilerPreview() { + val spoilerState = rememberSpoilerState() + val text = buildAnnotatedString { + append("Here's a really long spoiler: ") + val start = length + append("This is a very long piece of text that will definitely span multiple lines when displayed in a narrow container. It contains important plot details that you might not want to see until you're ready!") + addStringAnnotation(SPOILER_ANNOTATION_TAG, "long-spoiler", start, length) + append(" Tap to reveal.") + } + + Previews.Preview { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + SpoilerText( + text = text, + spoilerState = spoilerState, + style = MaterialTheme.typography.bodyLarge + ) + } + } +} + +@DayNightPreviews +@Composable +private fun StyledSpoilersPreview() { + val spoilerState = rememberSpoilerState() + val text = buildAnnotatedString { + append("Normal text, ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append("bold text, ") + } + val start1 = length + withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontSize = 18.sp)) { + append("bold spoiler") + } + addStringAnnotation(SPOILER_ANNOTATION_TAG, "styled-spoiler-1", start1, length) + append(", and ") + val start2 = length + withStyle(SpanStyle(fontWeight = FontWeight.Light, fontSize = 14.sp)) { + append("light spoiler") + } + addStringAnnotation(SPOILER_ANNOTATION_TAG, "styled-spoiler-2", start2, length) + append(".") + } + + Previews.Preview { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + SpoilerText( + text = text, + spoilerState = spoilerState, + style = MaterialTheme.typography.bodyLarge + ) + } + } +} + +@DayNightPreviews +@Composable +private fun ColoredSpoilersPreview() { + val spoilerState = rememberSpoilerState() + val text = buildAnnotatedString { + append("This text has ") + val start1 = length + withStyle(SpanStyle(color = Color.Red)) { + append("red spoiler") + } + addStringAnnotation(SPOILER_ANNOTATION_TAG, "red-spoiler", start1, length) + append(" and ") + val start2 = length + withStyle(SpanStyle(color = Color.Blue)) { + append("blue spoiler") + } + addStringAnnotation(SPOILER_ANNOTATION_TAG, "blue-spoiler", start2, length) + append(".") + } + + Previews.Preview { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + SpoilerText( + text = text, + spoilerState = spoilerState, + style = MaterialTheme.typography.bodyLarge + ) + } + } +} + +@DayNightPreviews +@Composable +private fun SharedStatePreview() { + val spoilerState = rememberSpoilerState() + + val text1 = buildAnnotatedString { + append("First message with ") + val start = length + append("shared spoiler") + addStringAnnotation(SPOILER_ANNOTATION_TAG, "shared-spoiler", start, length) + } + + val text2 = buildAnnotatedString { + append("Second message with the same ") + val start = length + append("shared spoiler") + addStringAnnotation(SPOILER_ANNOTATION_TAG, "shared-spoiler", start, length) + } + + Previews.Preview { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + SpoilerText( + text = text1, + spoilerState = spoilerState, + style = MaterialTheme.typography.bodyLarge + ) + SpoilerText( + text = text2, + spoilerState = spoilerState, + style = MaterialTheme.typography.bodyLarge + ) + } + } +} + +@DayNightPreviews +@Composable +private fun EntireMessageSpoilerPreview() { + val spoilerState = rememberSpoilerState() + val text = buildAnnotatedString { + val start = length + append("This entire message is a spoiler! You need to tap anywhere on it to reveal the content.") + addStringAnnotation(SPOILER_ANNOTATION_TAG, "entire-message", start, length) + } + + Previews.Preview { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + SpoilerText( + text = text, + spoilerState = spoilerState, + style = MaterialTheme.typography.bodyLarge + ) + } + } +} + +@DayNightPreviews +@Composable +private fun DifferentSizesPreview() { + val spoilerState = rememberSpoilerState() + + Previews.Preview { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + SpoilerText( + text = buildAnnotatedString { + append("Small text with ") + val start = length + append("small spoiler") + addStringAnnotation(SPOILER_ANNOTATION_TAG, "small", start, length) + }, + spoilerState = spoilerState, + fontSize = 12.sp + ) + + SpoilerText( + text = buildAnnotatedString { + append("Medium text with ") + val start = length + append("medium spoiler") + addStringAnnotation(SPOILER_ANNOTATION_TAG, "medium", start, length) + }, + spoilerState = spoilerState, + fontSize = 16.sp + ) + + SpoilerText( + text = buildAnnotatedString { + append("Large text with ") + val start = length + append("large spoiler") + addStringAnnotation(SPOILER_ANNOTATION_TAG, "large", start, length) + }, + spoilerState = spoilerState, + fontSize = 24.sp + ) + } + } +}