feat: inbuilt media picker
Signed-off-by: Infi <wingit@geist.ga>
This commit is contained in:
parent
de37d14139
commit
df57124ab4
|
|
@ -5,6 +5,9 @@
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,15 @@ import chat.revolt.api.RevoltHttp
|
||||||
import chat.revolt.api.RevoltJson
|
import chat.revolt.api.RevoltJson
|
||||||
import chat.revolt.api.schemas.AutumnError
|
import chat.revolt.api.schemas.AutumnError
|
||||||
import chat.revolt.api.schemas.AutumnId
|
import chat.revolt.api.schemas.AutumnId
|
||||||
import io.ktor.client.plugins.*
|
import io.ktor.client.plugins.onUpload
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.forms.MultiPartFormDataContent
|
||||||
import io.ktor.client.request.forms.*
|
import io.ktor.client.request.forms.formData
|
||||||
import io.ktor.client.statement.*
|
import io.ktor.client.request.post
|
||||||
import io.ktor.http.*
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.Headers
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
const val MAX_ATTACHMENTS_PER_MESSAGE = 5
|
const val MAX_ATTACHMENTS_PER_MESSAGE = 5
|
||||||
|
|
@ -18,6 +22,7 @@ data class FileArgs(
|
||||||
val file: File,
|
val file: File,
|
||||||
val filename: String,
|
val filename: String,
|
||||||
val contentType: String,
|
val contentType: String,
|
||||||
|
val pickerIdentifier: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun uploadToAutumn(
|
suspend fun uploadToAutumn(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package chat.revolt.api.settings
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
|
||||||
|
annotation class FeatureFlag(val name: String)
|
||||||
|
annotation class Treatment(val description: String)
|
||||||
|
|
||||||
|
@FeatureFlag("TiramisuFilePicker")
|
||||||
|
enum class FilePickerFeatureFlagVariates {
|
||||||
|
@Treatment("Use the READ_MEDIA_IMAGES or READ_MEDIA_VIDEO permissions introduced in Android Tiramisu")
|
||||||
|
TiramisuMediaPermissions,
|
||||||
|
|
||||||
|
@Treatment("Use the DocumentsUI picker introduced in Android KitKat")
|
||||||
|
DocumentsUI
|
||||||
|
}
|
||||||
|
|
||||||
|
object FeatureFlags {
|
||||||
|
@FeatureFlag("TiramisuFilePicker")
|
||||||
|
var filePickerType by mutableStateOf(FilePickerFeatureFlagVariates.TiramisuMediaPermissions)
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
|
@ -61,6 +62,7 @@ fun MessageField(
|
||||||
disabled: Boolean = false,
|
disabled: Boolean = false,
|
||||||
editMode: Boolean = false,
|
editMode: Boolean = false,
|
||||||
cancelEdit: () -> Unit = {},
|
cancelEdit: () -> Unit = {},
|
||||||
|
onFocusChange: (Boolean) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val placeholderResource = when (channelType) {
|
val placeholderResource = when (channelType) {
|
||||||
|
|
@ -74,7 +76,7 @@ fun MessageField(
|
||||||
val sendButtonVisible = (messageContent.isNotBlank() || forceSendButton) && !disabled
|
val sendButtonVisible = (messageContent.isNotBlank() || forceSendButton) && !disabled
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = Modifier
|
||||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
|
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
|
@ -85,9 +87,12 @@ fun MessageField(
|
||||||
enabled = !disabled,
|
enabled = !disabled,
|
||||||
textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface),
|
textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.focusRequester(focusRequester),
|
.focusRequester(focusRequester)
|
||||||
|
.onFocusChanged { state ->
|
||||||
|
onFocusChange(state.isFocused)
|
||||||
|
},
|
||||||
keyboardOptions = KeyboardOptions.Default,
|
keyboardOptions = KeyboardOptions.Default,
|
||||||
keyboardActions = KeyboardActions.Default,
|
keyboardActions = KeyboardActions.Default,
|
||||||
decorationBox = @Composable { innerTextField ->
|
decorationBox = @Composable { innerTextField ->
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
package chat.revolt.components.media
|
||||||
|
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
||||||
|
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.AssistChip
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.surfaceColorAtElevation
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.revolt.R
|
||||||
|
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
|
||||||
|
import com.bumptech.glide.integration.compose.GlideImage
|
||||||
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
|
|
||||||
|
data class Media(
|
||||||
|
val uri: Uri,
|
||||||
|
val width: Int,
|
||||||
|
val height: Int,
|
||||||
|
val aspectRatio: Float = width.toFloat() / height.toFloat()
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class, ExperimentalGlideComposeApi::class)
|
||||||
|
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||||
|
@Composable
|
||||||
|
fun InbuiltMediaPicker(
|
||||||
|
onOpenDocumentsUi: () -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onMediaSelected: (Media) -> Unit,
|
||||||
|
pendingMedia: List<String>,
|
||||||
|
disabled: Boolean = false,
|
||||||
|
) {
|
||||||
|
val mediaPermissionState = rememberMultiplePermissionsState(
|
||||||
|
listOf(
|
||||||
|
android.Manifest.permission.READ_MEDIA_IMAGES,
|
||||||
|
android.Manifest.permission.READ_MEDIA_VIDEO
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val images = remember { mutableStateListOf<Media>() }
|
||||||
|
|
||||||
|
BackHandler {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(mediaPermissionState.allPermissionsGranted) {
|
||||||
|
if (mediaPermissionState.allPermissionsGranted) {
|
||||||
|
val projection = arrayOf(
|
||||||
|
MediaStore.Images.ImageColumns._ID,
|
||||||
|
MediaStore.Images.ImageColumns.RESOLUTION
|
||||||
|
)
|
||||||
|
|
||||||
|
val selection: String? = null
|
||||||
|
val selectionArgs: Array<String>? = null
|
||||||
|
val sortOrder = MediaStore.Images.ImageColumns.DATE_ADDED + " DESC"
|
||||||
|
|
||||||
|
val queryUri = MediaStore.Files.getContentUri("external")
|
||||||
|
|
||||||
|
val cursor: Cursor? = context.contentResolver.query(
|
||||||
|
queryUri,
|
||||||
|
projection,
|
||||||
|
selection,
|
||||||
|
selectionArgs,
|
||||||
|
sortOrder
|
||||||
|
)
|
||||||
|
|
||||||
|
if (cursor != null) {
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
try {
|
||||||
|
val id =
|
||||||
|
cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns._ID))
|
||||||
|
val resolution =
|
||||||
|
cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.RESOLUTION))
|
||||||
|
val contentUri =
|
||||||
|
ContentUris.withAppendedId(
|
||||||
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (resolution == null) continue
|
||||||
|
|
||||||
|
images.add(
|
||||||
|
Media(
|
||||||
|
uri = contentUri,
|
||||||
|
width = resolution.split("×")[0].toInt(),
|
||||||
|
height = resolution.split("×")[1].toInt()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight(0.5f)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Crossfade(
|
||||||
|
targetState = mediaPermissionState.allPermissionsGranted,
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 300,
|
||||||
|
easing = FastOutSlowInEasing
|
||||||
|
),
|
||||||
|
label = "Media picker permission dialog"
|
||||||
|
) { state ->
|
||||||
|
if (!state) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.ux_file_request),
|
||||||
|
modifier = Modifier
|
||||||
|
.width(128.dp)
|
||||||
|
.height(128.dp),
|
||||||
|
contentDescription = null // decorative
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.file_picker_permission_request_header),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.file_picker_permission_request_body),
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = LocalContentColor.current.copy(
|
||||||
|
alpha = 0.5f
|
||||||
|
)
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Button(onClick = {
|
||||||
|
mediaPermissionState.launchMultiplePermissionRequest()
|
||||||
|
}) {
|
||||||
|
Text(text = stringResource(id = R.string.file_picker_permission_request_cta))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
AssistChip(
|
||||||
|
onClick = {
|
||||||
|
onOpenDocumentsUi()
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(text = stringResource(id = R.string.file_picker_chip_documents))
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_paperclip_24dp),
|
||||||
|
contentDescription = null // see label
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
LazyVerticalStaggeredGrid(
|
||||||
|
columns = StaggeredGridCells.Adaptive(100.dp),
|
||||||
|
verticalItemSpacing = 8.dp,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
content = {
|
||||||
|
items(images.size) { image ->
|
||||||
|
GlideImage(
|
||||||
|
model = images[image].uri.toString(),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.then(
|
||||||
|
if (images[image].uri.lastPathSegment in pendingMedia) {
|
||||||
|
Modifier.border(
|
||||||
|
width = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
shape = MaterialTheme.shapes.medium
|
||||||
|
)
|
||||||
|
} else Modifier
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
if (disabled) {
|
||||||
|
Modifier.alpha(0.5f)
|
||||||
|
} else {
|
||||||
|
Modifier.clickable {
|
||||||
|
onMediaSelected(images[image])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.width(100.dp)
|
||||||
|
.aspectRatio(images[image].aspectRatio),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package chat.revolt.screens.chat.views.channel
|
package chat.revolt.screens.chat.views.channel
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
|
@ -45,6 +47,7 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -58,9 +61,13 @@ import chat.revolt.activities.RevoltTweenInt
|
||||||
import chat.revolt.api.RevoltAPI
|
import chat.revolt.api.RevoltAPI
|
||||||
import chat.revolt.api.internals.ChannelUtils
|
import chat.revolt.api.internals.ChannelUtils
|
||||||
import chat.revolt.api.routes.microservices.autumn.FileArgs
|
import chat.revolt.api.routes.microservices.autumn.FileArgs
|
||||||
|
import chat.revolt.api.settings.FeatureFlag
|
||||||
|
import chat.revolt.api.settings.FeatureFlags
|
||||||
|
import chat.revolt.api.settings.FilePickerFeatureFlagVariates
|
||||||
import chat.revolt.components.chat.Message
|
import chat.revolt.components.chat.Message
|
||||||
import chat.revolt.components.chat.MessageField
|
import chat.revolt.components.chat.MessageField
|
||||||
import chat.revolt.components.chat.SystemMessage
|
import chat.revolt.components.chat.SystemMessage
|
||||||
|
import chat.revolt.components.media.InbuiltMediaPicker
|
||||||
import chat.revolt.components.screens.chat.AttachmentManager
|
import chat.revolt.components.screens.chat.AttachmentManager
|
||||||
import chat.revolt.components.screens.chat.ChannelHeader
|
import chat.revolt.components.screens.chat.ChannelHeader
|
||||||
import chat.revolt.components.screens.chat.ReplyManager
|
import chat.revolt.components.screens.chat.ReplyManager
|
||||||
|
|
@ -81,6 +88,7 @@ import com.discord.simpleast.core.simple.SimpleRenderer
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -104,27 +112,43 @@ fun ChannelScreen(
|
||||||
var messageContextSheetShown by remember { mutableStateOf(false) }
|
var messageContextSheetShown by remember { mutableStateOf(false) }
|
||||||
var messageContextSheetTarget by remember { mutableStateOf("") }
|
var messageContextSheetTarget by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
fun processFileUri(uri: Uri, pickerIdentifier: String? = null) {
|
||||||
|
DocumentFile.fromSingleUri(context, uri)?.let { file ->
|
||||||
|
val mFile = File(context.cacheDir, file.name ?: "attachment")
|
||||||
|
|
||||||
|
mFile.outputStream().use { output ->
|
||||||
|
@Suppress("Recycle")
|
||||||
|
context.contentResolver.openInputStream(uri)?.copyTo(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the file is already pending and was picked from the inbuilt picker, remove it.
|
||||||
|
// This is so you can "toggle" the file in the picker.
|
||||||
|
// If the file was picked via DocumentsUI we don't want toggling functionality as
|
||||||
|
// if you specifically opened it from DocumentsUI you probably want to send it anyway.
|
||||||
|
if (viewModel.pendingAttachments.any { it.pickerIdentifier == pickerIdentifier }) {
|
||||||
|
viewModel.pendingAttachments.removeIf { it.pickerIdentifier == pickerIdentifier }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.pendingAttachments.add(
|
||||||
|
FileArgs(
|
||||||
|
file = mFile,
|
||||||
|
contentType = file.type ?: "application/octet-stream",
|
||||||
|
filename = file.name ?: "attachment",
|
||||||
|
pickerIdentifier = pickerIdentifier
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val pickFileLauncher = rememberLauncherForActivityResult(
|
val pickFileLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.OpenMultipleDocuments()
|
contract = ActivityResultContracts.OpenMultipleDocuments()
|
||||||
) { uriList ->
|
) { uriList ->
|
||||||
uriList.let { uris ->
|
uriList.let { list ->
|
||||||
uris.forEach {
|
list.forEach { uri ->
|
||||||
DocumentFile.fromSingleUri(context, it)?.let { file ->
|
processFileUri(uri)
|
||||||
val mFile = File(context.cacheDir, file.name ?: "attachment")
|
|
||||||
|
|
||||||
mFile.outputStream().use { output ->
|
|
||||||
@Suppress("Recycle")
|
|
||||||
context.contentResolver.openInputStream(it)?.copyTo(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.pendingAttachments.add(
|
|
||||||
FileArgs(
|
|
||||||
file = mFile,
|
|
||||||
contentType = file.type ?: "application/octet-stream",
|
|
||||||
filename = file.name ?: "attachment"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -409,7 +433,21 @@ fun ChannelScreen(
|
||||||
},
|
},
|
||||||
onSendMessage = viewModel::sendPendingMessage,
|
onSendMessage = viewModel::sendPendingMessage,
|
||||||
onAddAttachment = {
|
onAddAttachment = {
|
||||||
pickFileLauncher.launch(arrayOf("*/*"))
|
val isTiramisu = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||||
|
|
||||||
|
@FeatureFlag("TiramisuFilePicker")
|
||||||
|
when {
|
||||||
|
FeatureFlags.filePickerType == FilePickerFeatureFlagVariates.TiramisuMediaPermissions
|
||||||
|
&& isTiramisu -> {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
viewModel.inbuiltFilePickerOpen = !viewModel.inbuiltFilePickerOpen
|
||||||
|
}
|
||||||
|
|
||||||
|
FeatureFlags.filePickerType == FilePickerFeatureFlagVariates.DocumentsUI
|
||||||
|
|| !isTiramisu -> {
|
||||||
|
pickFileLauncher.launch(arrayOf("*/*"))
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
channelType = channel.channelType,
|
channelType = channel.channelType,
|
||||||
channelName = channel.name ?: ChannelUtils.resolveDMName(channel) ?: stringResource(
|
channelName = channel.name ?: ChannelUtils.resolveDMName(channel) ?: stringResource(
|
||||||
|
|
@ -419,7 +457,46 @@ fun ChannelScreen(
|
||||||
disabled = viewModel.pendingAttachments.isNotEmpty() && viewModel.isSendingMessage,
|
disabled = viewModel.pendingAttachments.isNotEmpty() && viewModel.isSendingMessage,
|
||||||
editMode = viewModel.editingMessage != null,
|
editMode = viewModel.editingMessage != null,
|
||||||
cancelEdit = viewModel::cancelEditingMessage,
|
cancelEdit = viewModel::cancelEditingMessage,
|
||||||
|
onFocusChange = { nowFocused ->
|
||||||
|
if (nowFocused && viewModel.inbuiltFilePickerOpen) {
|
||||||
|
viewModel.inbuiltFilePickerOpen = false
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = viewModel.inbuiltFilePickerOpen) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
InbuiltMediaPicker(
|
||||||
|
onOpenDocumentsUi = {
|
||||||
|
pickFileLauncher.launch(arrayOf("*/*"))
|
||||||
|
viewModel.inbuiltFilePickerOpen = false
|
||||||
|
},
|
||||||
|
onClose = {
|
||||||
|
viewModel.inbuiltFilePickerOpen = false
|
||||||
|
},
|
||||||
|
onMediaSelected = { media ->
|
||||||
|
try {
|
||||||
|
processFileUri(
|
||||||
|
media.uri,
|
||||||
|
pickerIdentifier = media.uri.lastPathSegment
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is FileNotFoundException) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.file_picker_cannot_attach_file_invalid),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pendingMedia = viewModel.pendingAttachments
|
||||||
|
.filterNot { it.pickerIdentifier == null }
|
||||||
|
.map { it.pickerIdentifier!! },
|
||||||
|
disabled = viewModel.isSendingMessage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import chat.revolt.api.routes.server.fetchMember
|
||||||
import chat.revolt.api.routes.user.addUserIfUnknown
|
import chat.revolt.api.routes.user.addUserIfUnknown
|
||||||
import chat.revolt.api.schemas.Channel
|
import chat.revolt.api.schemas.Channel
|
||||||
import chat.revolt.api.schemas.Message
|
import chat.revolt.api.schemas.Message
|
||||||
|
import chat.revolt.api.settings.FeatureFlag
|
||||||
import chat.revolt.callbacks.UiCallback
|
import chat.revolt.callbacks.UiCallback
|
||||||
import chat.revolt.callbacks.UiCallbacks
|
import chat.revolt.callbacks.UiCallbacks
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
|
|
@ -58,6 +59,9 @@ class ChannelScreenViewModel : ViewModel() {
|
||||||
var pendingReplies = mutableStateListOf<SendMessageReply>()
|
var pendingReplies = mutableStateListOf<SendMessageReply>()
|
||||||
var pendingAttachments = mutableStateListOf<FileArgs>()
|
var pendingAttachments = mutableStateListOf<FileArgs>()
|
||||||
|
|
||||||
|
@FeatureFlag("TiramisuFilePicker")
|
||||||
|
var inbuiltFilePickerOpen by mutableStateOf(false)
|
||||||
|
|
||||||
var pendingUploadProgress by mutableFloatStateOf(0f)
|
var pendingUploadProgress by mutableFloatStateOf(0f)
|
||||||
|
|
||||||
var editingMessage by mutableStateOf<String?>(null)
|
var editingMessage by mutableStateOf<String?>(null)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:height="24dp"
|
||||||
|
android:width="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:pathData="M16.5,6V17.5A4,4 0 0,1 12.5,21.5A4,4 0 0,1 8.5,17.5V5A2.5,2.5 0 0,1 11,2.5A2.5,2.5 0 0,1 13.5,5V15.5A1,1 0 0,1 12.5,16.5A1,1 0 0,1 11.5,15.5V6H10V15.5A2.5,2.5 0 0,0 12.5,18A2.5,2.5 0 0,0 15,15.5V5A4,4 0 0,0 11,1A4,4 0 0,0 7,5V17.5A5.5,5.5 0 0,0 12.5,23A5.5,5.5 0 0,0 18,17.5V6H16.5Z" />
|
||||||
|
</vector>
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="95.914dp"
|
||||||
|
android:height="80.251dp"
|
||||||
|
android:viewportWidth="95.914"
|
||||||
|
android:viewportHeight="80.251">
|
||||||
|
<path
|
||||||
|
android:pathData="M4.557,40.125a38.721,40.125 0,1 0,77.443 0a38.721,40.125 0,1 0,-77.443 0z"
|
||||||
|
android:strokeWidth="7.76499"
|
||||||
|
android:fillColor="@color/material_dynamic_primary100"
|
||||||
|
android:fillAlpha="0.196078"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M13.333,40.125a29.945,31.031 0,1 0,59.891 0a29.945,31.031 0,1 0,-59.891 0z"
|
||||||
|
android:strokeWidth="6.00512"
|
||||||
|
android:fillColor="@color/material_dynamic_primary100"
|
||||||
|
android:fillAlpha="0.196078"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M24.1,40.125a19.179,19.874 0,1 0,38.358 0a19.179,19.874 0,1 0,-38.358 0z"
|
||||||
|
android:strokeWidth="3.84605"
|
||||||
|
android:fillColor="@color/material_dynamic_primary100"
|
||||||
|
android:fillAlpha="0.196078"/>
|
||||||
|
<path
|
||||||
|
android:pathData="m70.658,10.102 l-13.704,-3.441c-2.535,-0.637 -5.078,0.886 -5.715,3.421L44.356,37.489a4.71,4.71 0,0 0,3.421 5.715l36.543,9.177a4.71,4.71 0,0 0,5.715 -3.421l5.736,-22.84c0.637,-2.535 -0.908,-5.084 -3.421,-5.715l-18.272,-4.589z"
|
||||||
|
android:strokeWidth="2.35489"
|
||||||
|
android:fillColor="#db4e5b"/>
|
||||||
|
<path
|
||||||
|
android:pathData="m11.386,55.62 l4.774,3.257 3.159,-7.823 8.836,6.717 -19.927,5.672m23.179,-5.059 l-5.672,-19.927c-0.45,-1.58 -2.091,-2.482 -3.657,-2.036l-19.927,5.672a2.96,2.96 0,0 0,-2.036 3.657l5.672,19.927a2.96,2.96 0,0 0,3.657 2.036l19.927,-5.672a2.96,2.96 0,0 0,2.036 -3.657z"
|
||||||
|
android:strokeWidth="1.47992"
|
||||||
|
android:fillColor="@color/foreground"/>
|
||||||
|
</vector>
|
||||||
|
|
@ -285,6 +285,13 @@
|
||||||
<string name="media_viewer_share_video">Share video</string>
|
<string name="media_viewer_share_video">Share video</string>
|
||||||
<string name="media_viewer_share_image">Share image</string>
|
<string name="media_viewer_share_image">Share image</string>
|
||||||
|
|
||||||
|
<string name="file_picker_cannot_attach_file_invalid">This file is invalid and cannot be attached.</string>
|
||||||
|
<string name="file_picker_permission_request_header">We need your permission to access photos and videos</string>
|
||||||
|
<string name="file_picker_permission_request_body">You will be able to attach photos and videos to your messages afterwards.</string>
|
||||||
|
<string name="file_picker_permission_request_cta">Allow access</string>
|
||||||
|
<string name="file_picker_chip_documents">Attach a file</string>
|
||||||
|
<string name="file_picker_chip_camera">Take a photo</string>
|
||||||
|
|
||||||
<string name="spark_sidebar_settings_tutorial">The settings are in the sidebar</string>
|
<string name="spark_sidebar_settings_tutorial">The settings are in the sidebar</string>
|
||||||
<string name="spark_sidebar_settings_tutorial_description_1">You can open the sidebar by swiping from the left edge of the screen.</string>
|
<string name="spark_sidebar_settings_tutorial_description_1">You can open the sidebar by swiping from the left edge of the screen.</string>
|
||||||
<string name="spark_sidebar_settings_tutorial_description_2">Then long tap your profile picture to open the settings.</string>
|
<string name="spark_sidebar_settings_tutorial_description_2">Then long tap your profile picture to open the settings.</string>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue