feat: upload attachments
This commit is contained in:
parent
56d1c4ff57
commit
1ef7d6610b
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue