feat: upload attachments

This commit is contained in:
Infi 2023-01-07 01:16:34 +01:00
parent 56d1c4ff57
commit 1ef7d6610b
9 changed files with 288 additions and 43 deletions

View File

@ -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"
}

View File

@ -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<SendMessageReply> = emptyList(),
val attachments: List<String>?,
)
suspend fun sendMessage(
channelId: String,
content: String,
nonce: String? = ULID.makeNext(),
replies: List<SendMessageReply>? = null,
embed: Embed? = null
attachments: List<String>? = 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
)
)
}

View File

@ -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")
}
}
}

View File

@ -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,
)

View File

@ -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"
)

View File

@ -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(),
)

View File

@ -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<FileArgs>,
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))
}
}
}

View File

@ -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<Channel?>(null)
@ -87,6 +97,35 @@ class ChannelScreenViewModel : ViewModel() {
_showButtons = show
}
private var _attachments = mutableStateListOf<FileArgs>()
val attachments: List<FileArgs>
get() = _attachments
fun setAttachments(attachments: List<FileArgs>) {
_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<String>()
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
)
}
}
}

View File

@ -69,6 +69,7 @@
<string name="send_alt">Send</string>
<string name="add_attachment_alt">Add attachment</string>
<string name="remove_attachment_alt">Remove attachment</string>
<string name="show_more_alt">Show more</string>
<string name="tutorial">Welcome to Revolt\'s in-progress Android experience!</string>