feat: spoilered images (send and recv)

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2025-06-11 04:31:15 +02:00
parent bdd706fda5
commit a7c1a20fbc
8 changed files with 188 additions and 61 deletions

View File

@ -22,13 +22,13 @@ plugins {
val composeBomVersion = "2025.03.00" val composeBomVersion = "2025.03.00"
val accompanistVersion = "0.34.0" val accompanistVersion = "0.34.0"
val okhttpVersion = "4.12.0" val okhttpVersion = "4.12.0"
val navVersion = "2.8.7" val navVersion = "2.9.0"
val hiltVersion = "2.52" val hiltVersion = "2.52"
val glideVersion = "4.16.0" val glideVersion = "4.16.0"
val ktorVersion = "3.0.0-beta-2" val ktorVersion = "3.0.0-beta-2"
val media3Version = "1.5.0" val media3Version = "1.7.1"
val livekitVersion = "2.2.0" val livekitVersion = "2.2.0"
val material3Version = "1.4.0-alpha10" val material3Version = "1.4.0-alpha15"
val androidXTestVersion = "1.6.1" val androidXTestVersion = "1.6.1"
fun property(fileName: String, propertyName: String, fallbackEnv: String? = null): String? { fun property(fileName: String, propertyName: String, fallbackEnv: String? = null): String? {
@ -184,14 +184,14 @@ sentry {
dependencies { dependencies {
// Android/Kotlin Core // Android/Kotlin Core
implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.core:core-ktx:1.16.0")
implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.10") implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.20")
// Kotlinx - various first-party extensions for Kotlin // Kotlinx - various first-party extensions for Kotlin
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-cbor:1.6.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-cbor:1.6.1")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0")
implementation("androidx.profileinstaller:profileinstaller:1.3.1") implementation("androidx.profileinstaller:profileinstaller:1.4.1")
// Compose BOM // Compose BOM
val composeBom = platform("androidx.compose:compose-bom:$composeBomVersion") val composeBom = platform("androidx.compose:compose-bom:$composeBomVersion")
@ -200,16 +200,16 @@ dependencies {
androidTestImplementation(composeBom) androidTestImplementation(composeBom)
// Jetpack Compose // Jetpack Compose
implementation("androidx.compose.ui:ui:1.8.0-rc01") implementation("androidx.compose.ui:ui:1.8.2")
implementation("androidx.compose.ui:ui-util") implementation("androidx.compose.ui:ui-util")
implementation("androidx.compose.material3:material3:$material3Version") implementation("androidx.compose.material3:material3:$material3Version")
implementation("androidx.compose.material3:material3-window-size-class:$material3Version") implementation("androidx.compose.material3:material3-window-size-class:$material3Version")
implementation("androidx.compose.material:material-icons-core:1.7.8") implementation("androidx.compose.material:material-icons-core:1.7.8")
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.runtime:runtime-livedata") implementation("androidx.compose.runtime:runtime-livedata")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1")
implementation("androidx.activity:activity-compose:1.10.0") implementation("androidx.activity:activity-compose:1.10.1")
// Accompanist - Jetpack Compose Extensions // Accompanist - Jetpack Compose Extensions
implementation("com.google.accompanist:accompanist-systemuicontroller:$accompanistVersion") implementation("com.google.accompanist:accompanist-systemuicontroller:$accompanistVersion")
@ -243,26 +243,26 @@ dependencies {
implementation("com.mikepenz:aboutlibraries-core:11.3.0-rc02") implementation("com.mikepenz:aboutlibraries-core:11.3.0-rc02")
// Sentry - crash reporting // Sentry - crash reporting
implementation("io.sentry:sentry-android:7.16.0") implementation("io.sentry:sentry-android:8.13.2")
implementation("io.sentry:sentry-compose-android:7.16.0") implementation("io.sentry:sentry-compose-android:8.13.2")
// Other AndroidX libraries - used for various things and never seem to have a consistent version // Other AndroidX libraries
implementation("androidx.documentfile:documentfile:1.0.1") implementation("androidx.documentfile:documentfile:1.1.0")
implementation("androidx.browser:browser:1.8.0") implementation("androidx.browser:browser:1.8.0")
implementation("androidx.webkit:webkit:1.12.1") implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.core:core-splashscreen:1.2.0-beta01") implementation("androidx.core:core-splashscreen:1.2.0-beta02")
implementation("androidx.palette:palette-ktx:1.0.0") implementation("androidx.palette:palette-ktx:1.0.0")
// Libraries used for legacy View-based UI // Libraries used for legacy View-based UI
implementation("androidx.constraintlayout:constraintlayout:2.2.0") implementation("androidx.constraintlayout:constraintlayout:2.2.1")
implementation("androidx.appcompat:appcompat:1.7.0") implementation("androidx.appcompat:appcompat:1.7.1")
implementation("com.google.android.material:material:1.12.0") implementation("com.google.android.material:material:1.12.0")
// hCaptcha - captcha provider // hCaptcha - captcha provider
implementation("com.github.hcaptcha:hcaptcha-android-sdk:3.8.1") implementation("com.github.hcaptcha:hcaptcha-android-sdk:3.8.1")
// JDK Desugaring - polyfill for new Java APIs // JDK Desugaring - polyfill for new Java APIs
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
// AndroidX Media3 w/ ExoPlayer // AndroidX Media3 w/ ExoPlayer
implementation("androidx.media3:media3-exoplayer:$media3Version") implementation("androidx.media3:media3-exoplayer:$media3Version")
@ -273,15 +273,17 @@ dependencies {
// Compose libraries // Compose libraries
implementation("me.saket.telephoto:zoomable-image:1.0.0-alpha02") implementation("me.saket.telephoto:zoomable-image:1.0.0-alpha02")
implementation("me.saket.telephoto:zoomable-image-glide:1.0.0-alpha02") implementation("me.saket.telephoto:zoomable-image-glide:1.0.0-alpha02")
implementation("androidx.constraintlayout:constraintlayout-compose:1.1.0") implementation("androidx.constraintlayout:constraintlayout-compose:1.1.1")
implementation("dev.chrisbanes.haze:haze:1.6.4")
implementation("dev.chrisbanes.haze:haze-materials:1.6.4")
// ZXing - QR Code generation // ZXing - QR Code generation
implementation("com.google.zxing:core:3.5.3") implementation("com.google.zxing:core:3.5.3")
// Persistence // Persistence
implementation("app.cash.sqldelight:android-driver:2.0.1") implementation("app.cash.sqldelight:android-driver:2.0.1")
implementation("androidx.datastore:datastore:1.1.2") implementation("androidx.datastore:datastore:1.1.7")
implementation("androidx.datastore:datastore-preferences:1.1.2") implementation("androidx.datastore:datastore-preferences:1.1.7")
// Markup // Markup
implementation("org.jetbrains:markdown:0.7.3") implementation("org.jetbrains:markdown:0.7.3")
@ -292,7 +294,7 @@ dependencies {
// implementation "io.livekit:livekit-android:$livekit_version" // implementation "io.livekit:livekit-android:$livekit_version"
// Firebase - Cloud Messaging // Firebase - Cloud Messaging
implementation(platform("com.google.firebase:firebase-bom:33.9.0")) implementation(platform("com.google.firebase:firebase-bom:33.15.0"))
implementation("com.google.firebase:firebase-messaging") implementation("com.google.firebase:firebase-messaging")
// Shimmer - loading animations // Shimmer - loading animations

View File

@ -377,6 +377,16 @@ fun ShareTargetScreen(
attachments = viewModel.attachments, attachments = viewModel.attachments,
uploading = viewModel.attachmentsUploading, uploading = viewModel.attachmentsUploading,
uploadProgress = viewModel.attachmentProgress, uploadProgress = viewModel.attachmentProgress,
onToggleSpoiler = {
val index = viewModel.attachments
.indexOfFirst { a -> a.pickerIdentifier == it.pickerIdentifier }
if (index != -1) {
val attachment = viewModel.attachments[index]
viewModel.attachments[index] = attachment.copy(
spoiler = !attachment.spoiler
)
}
},
onRemove = {}, onRemove = {},
canRemove = false canRemove = false
) )

View File

@ -26,7 +26,8 @@ data class FileArgs(
val file: File, val file: File,
val filename: String, val filename: String,
val contentType: String, val contentType: String,
val pickerIdentifier: String? = null val spoiler: Boolean = false,
val pickerIdentifier: String? = null,
) )
suspend fun uploadToAutumn( suspend fun uploadToAutumn(

View File

@ -1,5 +1,6 @@
package chat.revolt.composables.chat package chat.revolt.composables.chat
import android.annotation.SuppressLint
import android.text.format.Formatter import android.text.format.Formatter
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -20,6 +21,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -34,6 +39,11 @@ import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.schemas.AutumnResource import chat.revolt.api.schemas.AutumnResource
import chat.revolt.composables.generic.RemoteImage import chat.revolt.composables.generic.RemoteImage
import chat.revolt.composables.media.AudioPlayer import chat.revolt.composables.media.AudioPlayer
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState
@Composable @Composable
fun FileAttachment(attachment: AutumnResource) { fun FileAttachment(attachment: AutumnResource) {
@ -71,9 +81,14 @@ fun FileAttachment(attachment: AutumnResource) {
} }
} }
@OptIn(ExperimentalHazeMaterialsApi::class)
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable @Composable
fun ImageAttachment(attachment: AutumnResource) { fun ImageAttachment(attachment: AutumnResource) {
val url = "$REVOLT_FILES/attachments/${attachment.id}/${attachment.filename}" val url = "$REVOLT_FILES/attachments/${attachment.id}/${attachment.filename}"
var spoilerShown by remember { mutableStateOf(false) }
val hazeState =
if (attachment.filename?.startsWith("SPOILER_") == true) rememberHazeState() else null
BoxWithConstraints { BoxWithConstraints {
RemoteImage( RemoteImage(
@ -83,9 +98,34 @@ fun ImageAttachment(attachment: AutumnResource) {
.width(attachment.metadata?.width?.toInt()?.dp ?: maxWidth) .width(attachment.metadata?.width?.toInt()?.dp ?: maxWidth)
.aspectRatio( .aspectRatio(
attachment.metadata!!.width!!.toFloat() / attachment.metadata.height!!.toFloat() attachment.metadata!!.width!!.toFloat() / attachment.metadata.height!!.toFloat()
)
.then(
if (hazeState != null) Modifier.hazeSource(state = hazeState)
else Modifier
), ),
description = attachment.filename ?: "Image" description = attachment.filename ?: "Image"
) )
if (attachment.filename?.startsWith("SPOILER_") == true && !spoilerShown) {
Box(
modifier = Modifier
.hazeEffect(state = hazeState, style = HazeMaterials.ultraThin())
.width(attachment.metadata?.width?.toInt()?.dp ?: maxWidth)
.aspectRatio(
attachment.metadata!!.width!!.toFloat() / attachment.metadata.height!!.toFloat()
)
.clickable { spoilerShown = true },
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.clip(MaterialTheme.shapes.medium)
.hazeEffect(state = hazeState, style = HazeMaterials.regular())
.padding(8.dp)
) {
Text(stringResource(R.string.attachment_spoiler))
}
}
}
} }
} }
@ -108,6 +148,7 @@ fun VideoPlayButton() {
) )
} }
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable @Composable
fun VideoAttachment(attachment: AutumnResource) { fun VideoAttachment(attachment: AutumnResource) {
val url = "$REVOLT_FILES/attachments/${attachment.id}/${attachment.filename}" val url = "$REVOLT_FILES/attachments/${attachment.id}/${attachment.filename}"

View File

@ -7,6 +7,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -21,10 +22,13 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
@ -37,6 +41,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@ -53,10 +58,16 @@ import java.io.File
@Composable @Composable
fun FilePreviewSheet( fun FilePreviewSheet(
args: FileArgs, canRemove: Boolean, onRemove: () -> Unit, onDismiss: () -> Unit args: FileArgs,
canRemove: Boolean,
onRemove: () -> Unit,
onToggleSpoiler: () -> Unit,
onDismiss: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
var localIsSpoiler by remember { mutableStateOf(args.spoiler) }
Column( Column(
Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@ -80,6 +91,39 @@ fun FilePreviewSheet(
color = LocalContentColor.current.copy(alpha = 0.6f), color = LocalContentColor.current.copy(alpha = 0.6f),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Box(
modifier = Modifier
.clip(MaterialTheme.shapes.medium)
.clickable {
onToggleSpoiler()
localIsSpoiler = !localIsSpoiler
}
.padding(top = 8.dp)
) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.attachment_preview_spoiler)
)
},
supportingContent = {
Text(
stringResource(R.string.attachment_preview_spoiler_description)
)
},
trailingContent = {
Switch(
checked = localIsSpoiler,
onCheckedChange = null,
)
},
colors = ListItemDefaults.colors().copy(
containerColor = Color.Transparent,
)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { Button(onClick = {
onDismiss() onDismiss()
@ -111,6 +155,7 @@ fun AttachmentManager(
uploading: Boolean, uploading: Boolean,
uploadProgress: Float = 0f, uploadProgress: Float = 0f,
onRemove: (FileArgs) -> Unit, onRemove: (FileArgs) -> Unit,
onToggleSpoiler: (FileArgs) -> Unit,
canRemove: Boolean = true, canRemove: Boolean = true,
canPreview: Boolean = true canPreview: Boolean = true
) { ) {
@ -126,18 +171,26 @@ fun AttachmentManager(
}, sheetState = sheetState }, sheetState = sheetState
) { ) {
previewingAttachment?.let { previewingAttachment?.let {
FilePreviewSheet(args = it, canRemove = canRemove, onRemove = { FilePreviewSheet(
onRemove(it) args = it,
scope.launch { canRemove = canRemove,
sheetState.hide() onRemove = {
showPreviewSheet = false onRemove(it)
scope.launch {
sheetState.hide()
showPreviewSheet = false
}
},
onToggleSpoiler = {
onToggleSpoiler(it)
},
onDismiss = {
scope.launch {
sheetState.hide()
showPreviewSheet = false
}
} }
}, onDismiss = { )
scope.launch {
sheetState.hide()
showPreviewSheet = false
}
})
} }
} }
} }
@ -159,20 +212,21 @@ fun AttachmentManager(
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
) { ) {
attachments.forEach { attachment -> attachments.forEach { attachment ->
Row(modifier = Modifier Row(
.padding(4.dp) modifier = Modifier
.clip(MaterialTheme.shapes.small) .padding(4.dp)
.clickable { .clip(MaterialTheme.shapes.small)
if (canPreview) { .clickable {
previewingAttachment = attachment if (canPreview) {
showPreviewSheet = true previewingAttachment = attachment
showPreviewSheet = true
}
} }
} .background(
.background( color = MaterialTheme.colorScheme.background,
color = MaterialTheme.colorScheme.background, shape = MaterialTheme.shapes.small
shape = MaterialTheme.shapes.small )
) .padding(8.dp)) {
.padding(8.dp)) {
Text(attachment.filename, maxLines = 1) Text(attachment.filename, maxLines = 1)
} }
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
@ -190,13 +244,14 @@ fun AttachmentManager(
@Preview @Preview
@Composable @Composable
fun AttachmentManagerPreview() { fun AttachmentManagerPreview() {
AttachmentManager(attachments = listOf( AttachmentManager(
FileArgs( attachments = listOf(
filename = "file1.png", contentType = "image/png", file = File("file1.png") FileArgs(
), FileArgs( filename = "file1.png", contentType = "image/png", file = File("file1.png")
filename = "file2.png", contentType = "image/png", file = File("file2.png") ), FileArgs(
), FileArgs( filename = "file2.png", contentType = "image/png", file = File("file2.png")
filename = "file3.png", contentType = "image/png", file = File("file3.png") ), FileArgs(
) filename = "file3.png", contentType = "image/png", file = File("file3.png")
), uploading = false, onRemove = {}) )
), uploading = false, onToggleSpoiler = {}, onRemove = {})
} }

View File

@ -61,7 +61,6 @@ import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -95,7 +94,6 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.Placeholder
@ -155,6 +153,7 @@ import com.valentinilk.shimmer.shimmer
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import logcat.logcat
import java.io.File import java.io.File
import kotlin.math.max import kotlin.math.max
@ -957,6 +956,21 @@ fun ChannelScreen(
canPreview = true, canPreview = true,
onRemove = { onRemove = {
viewModel.draftAttachments.remove(it) viewModel.draftAttachments.remove(it)
},
onToggleSpoiler = {
val index = viewModel.draftAttachments
.indexOfFirst { a -> a.pickerIdentifier == it.pickerIdentifier }
logcat {
"Toggling spoiler for attachment at index $index"
}
if (index != -1) {
val attachment =
viewModel.draftAttachments[index]
viewModel.draftAttachments[index] =
attachment.copy(
spoiler = !attachment.spoiler
)
}
} }
) )
} }

View File

@ -344,7 +344,7 @@ class ChannelScreenViewModel @Inject constructor(
try { try {
val id = uploadToAutumn( val id = uploadToAutumn(
it.file, it.file,
it.filename, if (it.spoiler) "SPOILER_${it.filename}" else it.filename,
"attachments", "attachments",
ContentType.parse(it.contentType), ContentType.parse(it.contentType),
onProgress = { current, total -> onProgress = { current, total ->

View File

@ -113,8 +113,12 @@
<string name="reply_mention_off">\@ off</string> <string name="reply_mention_off">\@ off</string>
<string name="too_many_replies">You can only reply to %1$d messages at a time.</string> <string name="too_many_replies">You can only reply to %1$d messages at a time.</string>
<string name="attachment_spoiler">Spoiler</string>
<string name="attachment_preview_remove">Remove</string> <string name="attachment_preview_remove">Remove</string>
<string name="attachment_preview_close">Close</string> <string name="attachment_preview_close">Close</string>
<string name="attachment_preview_spoiler">Mark as spoiler</string>
<string name="attachment_preview_spoiler_description">This attachment will only be revealed when tapped.</string>
<string name="emoji_category_smileys">Smileys &amp; Emotions</string> <string name="emoji_category_smileys">Smileys &amp; Emotions</string>
<string name="emoji_category_people">People &amp; Body</string> <string name="emoji_category_people">People &amp; Body</string>