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 accompanistVersion = "0.34.0"
val okhttpVersion = "4.12.0"
val navVersion = "2.8.7"
val navVersion = "2.9.0"
val hiltVersion = "2.52"
val glideVersion = "4.16.0"
val ktorVersion = "3.0.0-beta-2"
val media3Version = "1.5.0"
val media3Version = "1.7.1"
val livekitVersion = "2.2.0"
val material3Version = "1.4.0-alpha10"
val material3Version = "1.4.0-alpha15"
val androidXTestVersion = "1.6.1"
fun property(fileName: String, propertyName: String, fallbackEnv: String? = null): String? {
@ -184,14 +184,14 @@ sentry {
dependencies {
// Android/Kotlin Core
implementation("androidx.core:core-ktx:1.15.0")
implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.10")
implementation("androidx.core:core-ktx:1.16.0")
implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.20")
// Kotlinx - various first-party extensions for Kotlin
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-datetime:0.4.0")
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
implementation("androidx.profileinstaller:profileinstaller:1.4.1")
// Compose BOM
val composeBom = platform("androidx.compose:compose-bom:$composeBomVersion")
@ -200,16 +200,16 @@ dependencies {
androidTestImplementation(composeBom)
// 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.material3:material3:$material3Version")
implementation("androidx.compose.material3:material3-window-size-class:$material3Version")
implementation("androidx.compose.material:material-icons-core:1.7.8")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.runtime:runtime-livedata")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.activity:activity-compose:1.10.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1")
implementation("androidx.activity:activity-compose:1.10.1")
// Accompanist - Jetpack Compose Extensions
implementation("com.google.accompanist:accompanist-systemuicontroller:$accompanistVersion")
@ -243,26 +243,26 @@ dependencies {
implementation("com.mikepenz:aboutlibraries-core:11.3.0-rc02")
// Sentry - crash reporting
implementation("io.sentry:sentry-android:7.16.0")
implementation("io.sentry:sentry-compose-android:7.16.0")
implementation("io.sentry:sentry-android:8.13.2")
implementation("io.sentry:sentry-compose-android:8.13.2")
// Other AndroidX libraries - used for various things and never seem to have a consistent version
implementation("androidx.documentfile:documentfile:1.0.1")
// Other AndroidX libraries
implementation("androidx.documentfile:documentfile:1.1.0")
implementation("androidx.browser:browser:1.8.0")
implementation("androidx.webkit:webkit:1.12.1")
implementation("androidx.core:core-splashscreen:1.2.0-beta01")
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.core:core-splashscreen:1.2.0-beta02")
implementation("androidx.palette:palette-ktx:1.0.0")
// Libraries used for legacy View-based UI
implementation("androidx.constraintlayout:constraintlayout:2.2.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("com.google.android.material:material:1.12.0")
// hCaptcha - captcha provider
implementation("com.github.hcaptcha:hcaptcha-android-sdk:3.8.1")
// 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
implementation("androidx.media3:media3-exoplayer:$media3Version")
@ -273,15 +273,17 @@ dependencies {
// Compose libraries
implementation("me.saket.telephoto:zoomable-image: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
implementation("com.google.zxing:core:3.5.3")
// Persistence
implementation("app.cash.sqldelight:android-driver:2.0.1")
implementation("androidx.datastore:datastore:1.1.2")
implementation("androidx.datastore:datastore-preferences:1.1.2")
implementation("androidx.datastore:datastore:1.1.7")
implementation("androidx.datastore:datastore-preferences:1.1.7")
// Markup
implementation("org.jetbrains:markdown:0.7.3")
@ -292,7 +294,7 @@ dependencies {
// implementation "io.livekit:livekit-android:$livekit_version"
// 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")
// Shimmer - loading animations

View File

@ -377,6 +377,16 @@ fun ShareTargetScreen(
attachments = viewModel.attachments,
uploading = viewModel.attachmentsUploading,
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 = {},
canRemove = false
)

View File

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

View File

@ -1,5 +1,6 @@
package chat.revolt.composables.chat
import android.annotation.SuppressLint
import android.text.format.Formatter
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -20,6 +21,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.Modifier
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.composables.generic.RemoteImage
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
fun FileAttachment(attachment: AutumnResource) {
@ -71,9 +81,14 @@ fun FileAttachment(attachment: AutumnResource) {
}
}
@OptIn(ExperimentalHazeMaterialsApi::class)
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable
fun ImageAttachment(attachment: AutumnResource) {
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 {
RemoteImage(
@ -83,9 +98,34 @@ fun ImageAttachment(attachment: AutumnResource) {
.width(attachment.metadata?.width?.toInt()?.dp ?: maxWidth)
.aspectRatio(
attachment.metadata!!.width!!.toFloat() / attachment.metadata.height!!.toFloat()
)
.then(
if (hazeState != null) Modifier.hazeSource(state = hazeState)
else Modifier
),
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
fun VideoAttachment(attachment: AutumnResource) {
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.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -21,10 +22,13 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
@ -37,6 +41,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
@ -53,10 +58,16 @@ import java.io.File
@Composable
fun FilePreviewSheet(
args: FileArgs, canRemove: Boolean, onRemove: () -> Unit, onDismiss: () -> Unit
args: FileArgs,
canRemove: Boolean,
onRemove: () -> Unit,
onToggleSpoiler: () -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
var localIsSpoiler by remember { mutableStateOf(args.spoiler) }
Column(
Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
@ -80,6 +91,39 @@ fun FilePreviewSheet(
color = LocalContentColor.current.copy(alpha = 0.6f),
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)) {
Button(onClick = {
onDismiss()
@ -111,6 +155,7 @@ fun AttachmentManager(
uploading: Boolean,
uploadProgress: Float = 0f,
onRemove: (FileArgs) -> Unit,
onToggleSpoiler: (FileArgs) -> Unit,
canRemove: Boolean = true,
canPreview: Boolean = true
) {
@ -126,18 +171,26 @@ fun AttachmentManager(
}, sheetState = sheetState
) {
previewingAttachment?.let {
FilePreviewSheet(args = it, canRemove = canRemove, onRemove = {
onRemove(it)
scope.launch {
sheetState.hide()
showPreviewSheet = false
FilePreviewSheet(
args = it,
canRemove = canRemove,
onRemove = {
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)
) {
attachments.forEach { attachment ->
Row(modifier = Modifier
.padding(4.dp)
.clip(MaterialTheme.shapes.small)
.clickable {
if (canPreview) {
previewingAttachment = attachment
showPreviewSheet = true
Row(
modifier = Modifier
.padding(4.dp)
.clip(MaterialTheme.shapes.small)
.clickable {
if (canPreview) {
previewingAttachment = attachment
showPreviewSheet = true
}
}
}
.background(
color = MaterialTheme.colorScheme.background,
shape = MaterialTheme.shapes.small
)
.padding(8.dp)) {
.background(
color = MaterialTheme.colorScheme.background,
shape = MaterialTheme.shapes.small
)
.padding(8.dp)) {
Text(attachment.filename, maxLines = 1)
}
Spacer(modifier = Modifier.width(8.dp))
@ -190,13 +244,14 @@ fun AttachmentManager(
@Preview
@Composable
fun AttachmentManagerPreview() {
AttachmentManager(attachments = listOf(
FileArgs(
filename = "file1.png", contentType = "image/png", file = File("file1.png")
), FileArgs(
filename = "file2.png", contentType = "image/png", file = File("file2.png")
), FileArgs(
filename = "file3.png", contentType = "image/png", file = File("file3.png")
)
), uploading = false, onRemove = {})
AttachmentManager(
attachments = listOf(
FileArgs(
filename = "file1.png", contentType = "image/png", file = File("file1.png")
), FileArgs(
filename = "file2.png", contentType = "image/png", file = File("file2.png")
), FileArgs(
filename = "file3.png", contentType = "image/png", file = File("file3.png")
)
), uploading = false, onToggleSpoiler = {}, onRemove = {})
}

View File

@ -61,7 +61,6 @@ import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.Placeholder
@ -155,6 +153,7 @@ import com.valentinilk.shimmer.shimmer
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.datetime.Instant
import logcat.logcat
import java.io.File
import kotlin.math.max
@ -957,6 +956,21 @@ fun ChannelScreen(
canPreview = true,
onRemove = {
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 {
val id = uploadToAutumn(
it.file,
it.filename,
if (it.spoiler) "SPOILER_${it.filename}" else it.filename,
"attachments",
ContentType.parse(it.contentType),
onProgress = { current, total ->

View File

@ -113,8 +113,12 @@
<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="attachment_spoiler">Spoiler</string>
<string name="attachment_preview_remove">Remove</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_people">People &amp; Body</string>