From 1ef7d6610bcaed4ca7a0f25543ce8f50cf76347e Mon Sep 17 00:00:00 2001 From: Infi Date: Sat, 7 Jan 2023 01:16:34 +0100 Subject: [PATCH] feat: upload attachments --- app/build.gradle | 7 +- .../chat/revolt/api/routes/channel/Channel.kt | 21 ++- .../api/routes/microservices/autumn/Autumn.kt | 58 ++++++++ .../java/chat/revolt/api/schemas/Generic.kt | 10 ++ .../revolt/components/chat/MessageField.kt | 36 +++-- .../revolt/components/generic/PageHeader.kt | 3 +- .../screens/chat/AttachmentManager.kt | 57 ++++++++ .../screens/chat/views/ChannelScreen.kt | 138 ++++++++++++++++-- app/src/main/res/values/strings.xml | 1 + 9 files changed, 288 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/chat/revolt/api/routes/microservices/autumn/Autumn.kt create mode 100644 app/src/main/java/chat/revolt/components/screens/chat/AttachmentManager.kt diff --git a/app/build.gradle b/app/build.gradle index 7ca0f50a..26aad4dc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -87,9 +87,6 @@ dependencies { debugImplementation "androidx.compose.ui:ui-tooling:$compose_libraries_version" debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_libraries_version" - // Browser opening utility (used for legal links) - implementation "androidx.browser:browser:1.4.0" - // Hilt - Dependency Injection implementation "com.google.dagger:hilt-android:$hilt_version" implementation "androidx.hilt:hilt-navigation-compose:1.1.0-alpha01" @@ -104,7 +101,9 @@ dependencies { // AboutLibraries - automated OSS library attribution implementation "com.mikepenz:aboutlibraries-compose:$aboutlibraries_version" - // Jetpack DataStore - persistent storage + // Other AndroidX libraries - used for various things and never seem to have a consistent version + implementation 'androidx.documentfile:documentfile:1.0.1' + implementation "androidx.browser:browser:1.4.0" implementation "androidx.datastore:datastore-preferences:1.1.0-alpha01" implementation "androidx.datastore:datastore:1.1.0-alpha01" } diff --git a/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt b/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt index 1e191e9d..30a16ef7 100644 --- a/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt +++ b/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt @@ -4,7 +4,6 @@ import chat.revolt.api.RevoltAPI import chat.revolt.api.RevoltHttp import chat.revolt.api.RevoltJson import chat.revolt.api.internals.ULID -import chat.revolt.api.schemas.Embed import chat.revolt.api.schemas.Message import chat.revolt.api.schemas.MessagesInChannel import io.ktor.client.request.* @@ -59,23 +58,31 @@ data class SendMessageReply( val mention: Boolean ) +@kotlinx.serialization.Serializable +data class SendMessageBody( + val content: String, + val nonce: String = ULID.makeNext(), + val replies: List = emptyList(), + val attachments: List?, +) + suspend fun sendMessage( channelId: String, content: String, nonce: String? = ULID.makeNext(), replies: List? = null, - embed: Embed? = null + attachments: List? = null, ): String { val response = RevoltHttp.post("/channels/$channelId/messages") { headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken) contentType(ContentType.Application.Json) setBody( - mapOf( - "content" to content, - "nonce" to nonce, - "replies" to replies, - "embed" to embed + SendMessageBody( + content = content, + nonce = nonce ?: ULID.makeNext(), + replies = replies ?: emptyList(), + attachments = attachments ) ) } diff --git a/app/src/main/java/chat/revolt/api/routes/microservices/autumn/Autumn.kt b/app/src/main/java/chat/revolt/api/routes/microservices/autumn/Autumn.kt new file mode 100644 index 00000000..572da9dc --- /dev/null +++ b/app/src/main/java/chat/revolt/api/routes/microservices/autumn/Autumn.kt @@ -0,0 +1,58 @@ +package chat.revolt.api.routes.microservices.autumn + +import chat.revolt.api.REVOLT_FILES +import chat.revolt.api.RevoltHttp +import chat.revolt.api.RevoltJson +import chat.revolt.api.schemas.AutumnError +import chat.revolt.api.schemas.AutumnId +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.request.forms.* +import io.ktor.client.statement.* +import io.ktor.http.* +import java.io.File + +const val MAX_ATTACHMENTS_PER_MESSAGE = 5 + +data class FileArgs( + val file: File, + val filename: String, + val contentType: String, +) + +suspend fun uploadToAutumn( + file: File, + name: String, + tag: String, + contentType: ContentType, + onProgress: (Long, Long) -> Unit = { _, _ -> } +): String { + val uploadUrl = "$REVOLT_FILES/$tag" + + val response = RevoltHttp.post(uploadUrl) { + setBody(MultiPartFormDataContent( + formData { + append("file", file.readBytes(), Headers.build { + append(HttpHeaders.ContentType, contentType.toString()) + append(HttpHeaders.ContentDisposition, "filename=\"$name\"") + }) + } + )) + onUpload { bytesSentTotal, contentLength -> + onProgress(bytesSentTotal, contentLength) + } + } + + try { + val autumnId = RevoltJson.decodeFromString(AutumnId.serializer(), response.bodyAsText()) + return autumnId.id + } catch (e: Exception) { + try { + val error = RevoltJson.decodeFromString(AutumnError.serializer(), response.bodyAsText()) + throw Exception(error.type) + } catch (e: Exception) { + throw Exception("Unknown error") + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/schemas/Generic.kt b/app/src/main/java/chat/revolt/api/schemas/Generic.kt index 47d7b269..2c594ccd 100644 --- a/app/src/main/java/chat/revolt/api/schemas/Generic.kt +++ b/app/src/main/java/chat/revolt/api/schemas/Generic.kt @@ -37,4 +37,14 @@ data class Metadata( val type: String? = null, val width: Long? = null, val height: Long? = null +) + +@Serializable +data class AutumnId( + val id: String +) + +@Serializable +data class AutumnError( + val type: String, ) \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/components/chat/MessageField.kt b/app/src/main/java/chat/revolt/components/chat/MessageField.kt index a05baa41..ee0e93da 100644 --- a/app/src/main/java/chat/revolt/components/chat/MessageField.kt +++ b/app/src/main/java/chat/revolt/components/chat/MessageField.kt @@ -1,21 +1,21 @@ package chat.revolt.components.chat -import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Send import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -29,12 +29,15 @@ fun MessageField( onToggleButtons: (Boolean) -> Unit, messageContent: String, onMessageContentChange: (String) -> Unit, + onAddAttachment: () -> Unit, onSendMessage: () -> Unit, channelType: ChannelType, channelName: String, modifier: Modifier = Modifier, + forceSendButton: Boolean = false, + disabled: Boolean = false, ) { - val context = LocalContext.current + val focusRequester = remember { FocusRequester() } val placeholderResource = when (channelType) { ChannelType.DirectMessage -> R.string.message_field_placeholder_dm ChannelType.Group -> R.string.message_field_placeholder_group @@ -49,11 +52,8 @@ fun MessageField( Row(Modifier.weight(1f)) { ElevatedButton( onClick = { - Toast.makeText( - context, - "Placeholder for adding an attachment", - Toast.LENGTH_SHORT - ).show() + focusRequester.freeFocus() // hide keyboard because it's annoying + onAddAttachment() }, modifier = Modifier.size(56.dp), contentPadding = PaddingValues(0.dp), @@ -78,11 +78,11 @@ fun MessageField( Icons.Default.KeyboardArrowRight, contentDescription = stringResource(id = R.string.show_more_alt), modifier = Modifier - .size(24.dp + 8.dp) - .padding(vertical = 4.dp) .clickable(onClick = { onToggleButtons(true) }) + .size(24.dp + 8.dp) + .padding(vertical = 4.dp) ) } } @@ -91,7 +91,8 @@ fun MessageField( value = messageContent, onValueChange = onMessageContentChange, singleLine = false, - shape = RoundedCornerShape(100), + shape = MaterialTheme.shapes.extraLarge, + enabled = !disabled, placeholder = { Text( stringResource( @@ -109,14 +110,16 @@ fun MessageField( ), modifier = Modifier .weight(1f) - .padding(horizontal = 8.dp) + .padding(start = 8.dp) + .focusRequester(focusRequester) ) - // Send button (visible when text is entered) - AnimatedVisibility(visible = messageContent.isNotBlank()) { + // Send button (visible when text is entered or when forceSendButton is true) + AnimatedVisibility(visible = (messageContent.isNotBlank() || forceSendButton) && !disabled) { Button( onClick = onSendMessage, modifier = Modifier + .padding(start = 8.dp) .size(56.dp), contentPadding = PaddingValues(0.dp), shape = CircleShape @@ -136,9 +139,10 @@ fun MessageFieldPreview() { MessageField( showButtons = true, onToggleButtons = {}, - messageContent = "", + messageContent = "Hello world!", onMessageContentChange = {}, onSendMessage = {}, + onAddAttachment = {}, channelType = ChannelType.TextChannel, channelName = "general" ) diff --git a/app/src/main/java/chat/revolt/components/generic/PageHeader.kt b/app/src/main/java/chat/revolt/components/generic/PageHeader.kt index 44d85169..6e7963ab 100644 --- a/app/src/main/java/chat/revolt/components/generic/PageHeader.kt +++ b/app/src/main/java/chat/revolt/components/generic/PageHeader.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.sp @Composable fun PageHeader( text: String, + modifier: Modifier = Modifier, ) { Text( text = text, @@ -23,7 +24,7 @@ fun PageHeader( textAlign = TextAlign.Left, fontSize = 24.sp ), - modifier = Modifier + modifier = modifier .padding(horizontal = 15.dp, vertical = 15.dp) .fillMaxWidth(), ) diff --git a/app/src/main/java/chat/revolt/components/screens/chat/AttachmentManager.kt b/app/src/main/java/chat/revolt/components/screens/chat/AttachmentManager.kt new file mode 100644 index 00000000..0bfdcb75 --- /dev/null +++ b/app/src/main/java/chat/revolt/components/screens/chat/AttachmentManager.kt @@ -0,0 +1,57 @@ +package chat.revolt.components.screens.chat + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import chat.revolt.api.routes.microservices.autumn.FileArgs +import chat.revolt.R + +@Composable +fun AttachmentManager( + attachments: List, + uploading: Boolean, + onRemove: (FileArgs) -> Unit, +) { + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 8.dp) + ) { + AnimatedVisibility(uploading) { + CircularProgressIndicator() + } + attachments.forEach { attachment -> + Row( + modifier = Modifier + .padding(4.dp) + .border( + 1.dp, + MaterialTheme.colorScheme.onBackground.copy(alpha = 0.3f), + MaterialTheme.shapes.small + ) + .clickable { + onRemove(attachment) + } + .padding(8.dp) + ) { + Text(attachment.filename, maxLines = 1) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.remove_attachment_alt) + ) + } + Spacer(modifier = Modifier.width(8.dp)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/views/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/ChannelScreen.kt index 435a08e6..ef681a6d 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/ChannelScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/ChannelScreen.kt @@ -2,6 +2,8 @@ package chat.revolt.screens.chat.views import android.util.Log import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.* import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -24,6 +26,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel @@ -43,10 +46,17 @@ import chat.revolt.R import chat.revolt.RevoltTweenFloat import chat.revolt.RevoltTweenInt import chat.revolt.api.routes.channel.fetchMessagesFromChannel +import chat.revolt.api.routes.microservices.autumn.FileArgs import chat.revolt.components.chat.MessageField import chat.revolt.components.generic.CollapsibleCard import chat.revolt.components.generic.PageHeader import chat.revolt.components.screens.chat.ChannelIcon +import androidx.compose.runtime.getValue +import chat.revolt.api.routes.microservices.autumn.MAX_ATTACHMENTS_PER_MESSAGE +import chat.revolt.api.routes.microservices.autumn.uploadToAutumn +import chat.revolt.components.screens.chat.AttachmentManager +import io.ktor.http.* +import java.io.File class ChannelScreenViewModel : ViewModel() { private var _channel by mutableStateOf(null) @@ -87,6 +97,35 @@ class ChannelScreenViewModel : ViewModel() { _showButtons = show } + private var _attachments = mutableStateListOf() + val attachments: List + get() = _attachments + + fun setAttachments(attachments: List) { + _attachments.clear() + _attachments.addAll(attachments) + } + + fun addAttachment(fileArgs: FileArgs) { + _attachments.add(fileArgs) + } + + fun removeAttachment(fileArgs: FileArgs) { + _attachments.remove(fileArgs) + } + + private fun popAttachmentBatch() { + setAttachments(_attachments.drop(MAX_ATTACHMENTS_PER_MESSAGE)) + } + + private var _sendingMessage by mutableStateOf(false) + val sendingMessage: Boolean + get() = _sendingMessage + + private fun setSendingMessage(sending: Boolean) { + _sendingMessage = sending + } + inner class ChannelScreenCallback : RealtimeSocket.ChannelCallback { override fun onMessage(message: MessageFrame) { viewModelScope.launch { @@ -173,10 +212,37 @@ class ChannelScreenViewModel : ViewModel() { } fun sendPendingMessage() { + + setSendingMessage(true) viewModelScope.launch { - sendMessage(channel!!.id!!, messageContent) + val attachmentIds = arrayListOf() + + attachments.take(MAX_ATTACHMENTS_PER_MESSAGE).forEach { + try { + val id = uploadToAutumn( + it.file, + it.filename, + "attachments", + ContentType.parse(it.contentType) + ) + Log.d("ChannelScreen", "Uploaded attachment with id $id") + attachmentIds.add(id) + } catch (e: Exception) { + Log.e("ChannelScreen", "Failed to upload attachment", e) + return@launch + } + } + + sendMessage( + channel!!.id!!, + messageContent, + attachments = if (attachmentIds.isEmpty()) null else attachmentIds + ) + + _messageContent = "" + popAttachmentBatch() + setSendingMessage(false) } - _messageContent = "" } fun typingMessageResource(): Int { @@ -221,7 +287,10 @@ fun ChannelInfoScreen( channelType = channel.channelType!!, modifier = Modifier.size(32.dp) ) - PageHeader(text = channel.name ?: channel.id!!) + PageHeader( + text = channel.name ?: channel.id!!, + modifier = Modifier.offset((-8).dp, 0.dp) + ) } Column(modifier = Modifier.weight(1f)) { @@ -277,10 +346,39 @@ fun ChannelScreen( viewModel: ChannelScreenViewModel = viewModel() ) { val channel = viewModel.channel + + val context = LocalContext.current val scrollState = rememberScrollState() - val channelInfoOpen = remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() + val channelInfoOpen = remember { mutableStateOf(false) } + + val pickFileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenMultipleDocuments() + ) { uriList -> + uriList.let { uris -> + uris.forEach { + DocumentFile.fromSingleUri(context, it)?.let docfile@{ file -> + val mFile = File(context.cacheDir, file.name ?: "attachment") + + mFile.outputStream().use { output -> + @Suppress("Recycle") + context.contentResolver.openInputStream(it)?.copyTo(output) + } + + viewModel.addAttachment( + FileArgs( + file = mFile, + contentType = file.type ?: "application/octet-stream", + filename = file.name ?: "attachment" + ) + ) + } + } + + } + } + LaunchedEffect(channelId) { viewModel.fetchChannel(channelId) } @@ -386,17 +484,27 @@ fun ChannelScreen( } } - - channel.channelType?.let { - MessageField( - showButtons = viewModel.showButtons, - onToggleButtons = viewModel::setShowButtons, - messageContent = viewModel.messageContent, - onMessageContentChange = viewModel::setMessageContent, - onSendMessage = viewModel::sendPendingMessage, - channelType = it, - channelName = channel.name ?: channel.id!! + AnimatedVisibility(visible = viewModel.attachments.isNotEmpty()) { + AttachmentManager( + attachments = viewModel.attachments, + uploading = viewModel.sendingMessage, + onRemove = viewModel::removeAttachment ) } + + MessageField( + showButtons = viewModel.showButtons, + onToggleButtons = viewModel::setShowButtons, + messageContent = viewModel.messageContent, + onMessageContentChange = viewModel::setMessageContent, + onSendMessage = viewModel::sendPendingMessage, + onAddAttachment = { + pickFileLauncher.launch(arrayOf("*/*")) + }, + channelType = channel.channelType!!, + channelName = channel.name ?: channel.id!!, + forceSendButton = viewModel.attachments.isNotEmpty(), + disabled = viewModel.sendingMessage + ) } -} \ No newline at end of file +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cc7be5f8..69762573 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -69,6 +69,7 @@ Send Add attachment + Remove attachment Show more Welcome to Revolt\'s in-progress Android experience!