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-tooling:$compose_libraries_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$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 // Hilt - Dependency Injection
implementation "com.google.dagger:hilt-android:$hilt_version" implementation "com.google.dagger:hilt-android:$hilt_version"
implementation "androidx.hilt:hilt-navigation-compose:1.1.0-alpha01" implementation "androidx.hilt:hilt-navigation-compose:1.1.0-alpha01"
@ -104,7 +101,9 @@ dependencies {
// AboutLibraries - automated OSS library attribution // AboutLibraries - automated OSS library attribution
implementation "com.mikepenz:aboutlibraries-compose:$aboutlibraries_version" 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-preferences:1.1.0-alpha01"
implementation "androidx.datastore:datastore: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.RevoltHttp
import chat.revolt.api.RevoltJson import chat.revolt.api.RevoltJson
import chat.revolt.api.internals.ULID import chat.revolt.api.internals.ULID
import chat.revolt.api.schemas.Embed
import chat.revolt.api.schemas.Message import chat.revolt.api.schemas.Message
import chat.revolt.api.schemas.MessagesInChannel import chat.revolt.api.schemas.MessagesInChannel
import io.ktor.client.request.* import io.ktor.client.request.*
@ -59,23 +58,31 @@ data class SendMessageReply(
val mention: Boolean 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( suspend fun sendMessage(
channelId: String, channelId: String,
content: String, content: String,
nonce: String? = ULID.makeNext(), nonce: String? = ULID.makeNext(),
replies: List<SendMessageReply>? = null, replies: List<SendMessageReply>? = null,
embed: Embed? = null attachments: List<String>? = null,
): String { ): String {
val response = RevoltHttp.post("/channels/$channelId/messages") { val response = RevoltHttp.post("/channels/$channelId/messages") {
headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken) headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody( setBody(
mapOf( SendMessageBody(
"content" to content, content = content,
"nonce" to nonce, nonce = nonce ?: ULID.makeNext(),
"replies" to replies, replies = replies ?: emptyList(),
"embed" to embed 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

@ -38,3 +38,13 @@ data class Metadata(
val width: Long? = null, val width: Long? = null,
val height: 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 package chat.revolt.components.chat
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -29,12 +29,15 @@ fun MessageField(
onToggleButtons: (Boolean) -> Unit, onToggleButtons: (Boolean) -> Unit,
messageContent: String, messageContent: String,
onMessageContentChange: (String) -> Unit, onMessageContentChange: (String) -> Unit,
onAddAttachment: () -> Unit,
onSendMessage: () -> Unit, onSendMessage: () -> Unit,
channelType: ChannelType, channelType: ChannelType,
channelName: String, channelName: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
forceSendButton: Boolean = false,
disabled: Boolean = false,
) { ) {
val context = LocalContext.current val focusRequester = remember { FocusRequester() }
val placeholderResource = when (channelType) { val placeholderResource = when (channelType) {
ChannelType.DirectMessage -> R.string.message_field_placeholder_dm ChannelType.DirectMessage -> R.string.message_field_placeholder_dm
ChannelType.Group -> R.string.message_field_placeholder_group ChannelType.Group -> R.string.message_field_placeholder_group
@ -49,11 +52,8 @@ fun MessageField(
Row(Modifier.weight(1f)) { Row(Modifier.weight(1f)) {
ElevatedButton( ElevatedButton(
onClick = { onClick = {
Toast.makeText( focusRequester.freeFocus() // hide keyboard because it's annoying
context, onAddAttachment()
"Placeholder for adding an attachment",
Toast.LENGTH_SHORT
).show()
}, },
modifier = Modifier.size(56.dp), modifier = Modifier.size(56.dp),
contentPadding = PaddingValues(0.dp), contentPadding = PaddingValues(0.dp),
@ -78,11 +78,11 @@ fun MessageField(
Icons.Default.KeyboardArrowRight, Icons.Default.KeyboardArrowRight,
contentDescription = stringResource(id = R.string.show_more_alt), contentDescription = stringResource(id = R.string.show_more_alt),
modifier = Modifier modifier = Modifier
.size(24.dp + 8.dp)
.padding(vertical = 4.dp)
.clickable(onClick = { .clickable(onClick = {
onToggleButtons(true) onToggleButtons(true)
}) })
.size(24.dp + 8.dp)
.padding(vertical = 4.dp)
) )
} }
} }
@ -91,7 +91,8 @@ fun MessageField(
value = messageContent, value = messageContent,
onValueChange = onMessageContentChange, onValueChange = onMessageContentChange,
singleLine = false, singleLine = false,
shape = RoundedCornerShape(100), shape = MaterialTheme.shapes.extraLarge,
enabled = !disabled,
placeholder = { placeholder = {
Text( Text(
stringResource( stringResource(
@ -109,14 +110,16 @@ fun MessageField(
), ),
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(horizontal = 8.dp) .padding(start = 8.dp)
.focusRequester(focusRequester)
) )
// Send button (visible when text is entered) // Send button (visible when text is entered or when forceSendButton is true)
AnimatedVisibility(visible = messageContent.isNotBlank()) { AnimatedVisibility(visible = (messageContent.isNotBlank() || forceSendButton) && !disabled) {
Button( Button(
onClick = onSendMessage, onClick = onSendMessage,
modifier = Modifier modifier = Modifier
.padding(start = 8.dp)
.size(56.dp), .size(56.dp),
contentPadding = PaddingValues(0.dp), contentPadding = PaddingValues(0.dp),
shape = CircleShape shape = CircleShape
@ -136,9 +139,10 @@ fun MessageFieldPreview() {
MessageField( MessageField(
showButtons = true, showButtons = true,
onToggleButtons = {}, onToggleButtons = {},
messageContent = "", messageContent = "Hello world!",
onMessageContentChange = {}, onMessageContentChange = {},
onSendMessage = {}, onSendMessage = {},
onAddAttachment = {},
channelType = ChannelType.TextChannel, channelType = ChannelType.TextChannel,
channelName = "general" channelName = "general"
) )

View File

@ -15,6 +15,7 @@ import androidx.compose.ui.unit.sp
@Composable @Composable
fun PageHeader( fun PageHeader(
text: String, text: String,
modifier: Modifier = Modifier,
) { ) {
Text( Text(
text = text, text = text,
@ -23,7 +24,7 @@ fun PageHeader(
textAlign = TextAlign.Left, textAlign = TextAlign.Left,
fontSize = 24.sp fontSize = 24.sp
), ),
modifier = Modifier modifier = modifier
.padding(horizontal = 15.dp, vertical = 15.dp) .padding(horizontal = 15.dp, vertical = 15.dp)
.fillMaxWidth(), .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.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@ -43,10 +46,17 @@ import chat.revolt.R
import chat.revolt.RevoltTweenFloat import chat.revolt.RevoltTweenFloat
import chat.revolt.RevoltTweenInt import chat.revolt.RevoltTweenInt
import chat.revolt.api.routes.channel.fetchMessagesFromChannel 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.chat.MessageField
import chat.revolt.components.generic.CollapsibleCard import chat.revolt.components.generic.CollapsibleCard
import chat.revolt.components.generic.PageHeader import chat.revolt.components.generic.PageHeader
import chat.revolt.components.screens.chat.ChannelIcon 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() { class ChannelScreenViewModel : ViewModel() {
private var _channel by mutableStateOf<Channel?>(null) private var _channel by mutableStateOf<Channel?>(null)
@ -87,6 +97,35 @@ class ChannelScreenViewModel : ViewModel() {
_showButtons = show _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 { inner class ChannelScreenCallback : RealtimeSocket.ChannelCallback {
override fun onMessage(message: MessageFrame) { override fun onMessage(message: MessageFrame) {
viewModelScope.launch { viewModelScope.launch {
@ -173,10 +212,37 @@ class ChannelScreenViewModel : ViewModel() {
} }
fun sendPendingMessage() { fun sendPendingMessage() {
setSendingMessage(true)
viewModelScope.launch { 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 { fun typingMessageResource(): Int {
@ -221,7 +287,10 @@ fun ChannelInfoScreen(
channelType = channel.channelType!!, channelType = channel.channelType!!,
modifier = Modifier.size(32.dp) 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)) { Column(modifier = Modifier.weight(1f)) {
@ -277,10 +346,39 @@ fun ChannelScreen(
viewModel: ChannelScreenViewModel = viewModel() viewModel: ChannelScreenViewModel = viewModel()
) { ) {
val channel = viewModel.channel val channel = viewModel.channel
val context = LocalContext.current
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val channelInfoOpen = remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope() 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) { LaunchedEffect(channelId) {
viewModel.fetchChannel(channelId) viewModel.fetchChannel(channelId)
} }
@ -386,17 +484,27 @@ fun ChannelScreen(
} }
} }
AnimatedVisibility(visible = viewModel.attachments.isNotEmpty()) {
channel.channelType?.let { AttachmentManager(
MessageField( attachments = viewModel.attachments,
showButtons = viewModel.showButtons, uploading = viewModel.sendingMessage,
onToggleButtons = viewModel::setShowButtons, onRemove = viewModel::removeAttachment
messageContent = viewModel.messageContent,
onMessageContentChange = viewModel::setMessageContent,
onSendMessage = viewModel::sendPendingMessage,
channelType = it,
channelName = channel.name ?: channel.id!!
) )
} }
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="send_alt">Send</string>
<string name="add_attachment_alt">Add attachment</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="show_more_alt">Show more</string>
<string name="tutorial">Welcome to Revolt\'s in-progress Android experience!</string> <string name="tutorial">Welcome to Revolt\'s in-progress Android experience!</string>