diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1e316fdc..7ba7aadf 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -86,6 +86,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/chat/revolt/activities/ShareTargetActivity.kt b/app/src/main/java/chat/revolt/activities/ShareTargetActivity.kt
new file mode 100644
index 00000000..797bda36
--- /dev/null
+++ b/app/src/main/java/chat/revolt/activities/ShareTargetActivity.kt
@@ -0,0 +1,461 @@
+package chat.revolt.activities
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.os.Parcelable
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.setContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+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.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.surfaceColorAtElevation
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.core.view.WindowCompat
+import androidx.documentfile.provider.DocumentFile
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import chat.revolt.R
+import chat.revolt.api.RevoltAPI
+import chat.revolt.api.internals.ChannelUtils
+import chat.revolt.api.routes.channel.sendMessage
+import chat.revolt.api.routes.microservices.autumn.FileArgs
+import chat.revolt.api.routes.microservices.autumn.MAX_ATTACHMENTS_PER_MESSAGE
+import chat.revolt.api.routes.microservices.autumn.uploadToAutumn
+import chat.revolt.api.schemas.ChannelType
+import chat.revolt.api.settings.GlobalState
+import chat.revolt.api.settings.SyncedSettings
+import chat.revolt.components.chat.NativeMessageField
+import chat.revolt.components.emoji.EmojiPicker
+import chat.revolt.components.generic.presenceFromStatus
+import chat.revolt.components.screens.chat.AttachmentManager
+import chat.revolt.components.screens.chat.drawer.server.DrawerChannel
+import chat.revolt.components.screens.chat.drawer.server.DrawerChannelIconType
+import chat.revolt.persistence.KVStorage
+import chat.revolt.screens.chat.views.channel.BottomPane
+import chat.revolt.ui.theme.RevoltTheme
+import dagger.hilt.android.AndroidEntryPoint
+import dagger.hilt.android.lifecycle.HiltViewModel
+import io.ktor.http.ContentType
+import kotlinx.coroutines.launch
+import java.io.File
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class ShareTargetActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val text: String? = intent.getStringExtra(Intent.EXTRA_TEXT)
+ val media: List = when (intent?.action) {
+ // We receive one of something. Could be text, could be media.
+ Intent.ACTION_SEND -> {
+ when {
+ // No media if we receive text/plain
+ intent.type == "text/plain" -> {
+ listOf()
+ }
+
+ // Otherwise, we receive a single Uri
+ else -> {
+ listOf(
+ when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
+ intent.getParcelableExtra(
+ Intent.EXTRA_STREAM,
+ Parcelable::class.java
+ ) as? Uri
+ }
+
+ else -> {
+ @Suppress("DEPRECATION")
+ intent.getParcelableExtra(Intent.EXTRA_STREAM)
+ }
+ }
+ )
+ }
+ }
+ }
+
+ // We receive multiple URIs, definitely media
+ Intent.ACTION_SEND_MULTIPLE -> {
+ try {
+ val bundle: ArrayList? = when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
+ @Suppress("UNCHECKED_CAST")
+ intent.getParcelableArrayListExtra(
+ Intent.EXTRA_STREAM,
+ Parcelable::class.java
+ ) as? ArrayList
+ }
+
+ else -> {
+ @Suppress("DEPRECATION")
+ intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
+ }
+ }
+
+ bundle ?: listOf()
+ } catch (e: Exception) {
+ Log.e("ShareTargetActivity", "Failed to get multiple URIs", e)
+ listOf()
+ }
+ }
+
+ // We don't know what we're receiving
+ else -> {
+ Toast.makeText(
+ this,
+ getString(R.string.share_target_invalid_intent),
+ Toast.LENGTH_SHORT
+ ).show()
+
+ finish()
+ return
+ }
+ }
+
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+
+ setContent {
+ ShareTargetScreen(
+ text = text,
+ media = media.filterNotNull(),
+ onFinished = { finish() }
+ )
+ }
+ }
+}
+
+@HiltViewModel
+class ShareTargetScreenViewModel @Inject constructor(
+ private val kvStorage: KVStorage,
+) : ViewModel() {
+ var apiIsReady by mutableStateOf(false)
+
+ var messageContent by mutableStateOf("")
+ var attachments = mutableStateListOf()
+ var attachmentsUploading by mutableStateOf(false)
+ var attachmentProgress by mutableFloatStateOf(0f)
+ var activeBottomPane by mutableStateOf(BottomPane.None)
+
+ suspend fun isLoggedIn(): Boolean {
+ return kvStorage.get("sessionToken") != null
+ }
+
+ suspend fun initialiseAPI() {
+ if (!RevoltAPI.isLoggedIn()) {
+ val token = kvStorage.get("sessionToken") ?: return
+ RevoltAPI.loginAs(token)
+ RevoltAPI.initialize()
+ }
+ apiIsReady = true
+ }
+
+ fun send(channelId: String, onFinished: () -> Unit) {
+ viewModelScope.launch {
+ val attachmentIds = arrayListOf()
+ val takenAttachments = attachments.take(MAX_ATTACHMENTS_PER_MESSAGE)
+ val totalTaken = takenAttachments.size
+
+ takenAttachments.forEachIndexed { index, it ->
+ try {
+ val id = uploadToAutumn(
+ it.file,
+ it.filename,
+ "attachments",
+ ContentType.parse(it.contentType),
+ onProgress = { current, total ->
+ attachmentProgress =
+ ((current.toFloat() / total.toFloat()) / totalTaken.toFloat()) + (index.toFloat() / totalTaken.toFloat())
+ }
+ )
+ attachmentIds.add(id)
+ } catch (e: Exception) {
+ return@launch
+ }
+ }
+
+ sendMessage(
+ channelId = channelId,
+ content = messageContent,
+ attachments = attachmentIds
+ )
+
+ onFinished()
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ShareTargetScreen(
+ text: String?,
+ media: List?,
+ onFinished: () -> Unit = {},
+ viewModel: ShareTargetScreenViewModel = hiltViewModel()
+) {
+ val context = LocalContext.current
+
+ LaunchedEffect(Unit) {
+ if (!viewModel.isLoggedIn()) {
+ Toast.makeText(
+ context,
+ context.getString(R.string.share_target_login_first),
+ Toast.LENGTH_SHORT
+ ).show()
+
+ onFinished()
+ return@LaunchedEffect
+ }
+
+ viewModel.initialiseAPI()
+ }
+
+ LaunchedEffect(Unit) {
+ media?.forEach { uri ->
+ 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)
+ }
+
+ viewModel.attachments.add(
+ FileArgs(
+ file = mFile,
+ contentType = file.type ?: "application/octet-stream",
+ filename = file.name ?: "attachment",
+ pickerIdentifier = null
+ )
+ )
+ }
+ }
+
+ text?.let {
+ viewModel.messageContent = it
+ }
+ }
+
+ var channelSearchContent by remember { mutableStateOf("") }
+ var selectedChannel by rememberSaveable { mutableStateOf(null) }
+
+ RevoltTheme(
+ requestedTheme = GlobalState.theme,
+ colourOverrides = SyncedSettings.android.colourOverrides
+ ) {
+ Scaffold(
+ topBar = {
+ TopAppBar(title = {
+ Text(text = stringResource(R.string.share))
+ })
+ }
+ ) { pv ->
+ Surface(
+ modifier = Modifier
+ .padding(pv)
+ .background(MaterialTheme.colorScheme.background)
+ .fillMaxSize()
+ ) {
+ if (!viewModel.apiIsReady) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(48.dp)
+ )
+ }
+ return@Surface
+ }
+
+ Column {
+ OutlinedTextField(
+ value = channelSearchContent,
+ onValueChange = {
+ channelSearchContent = it
+ },
+ label = {
+ Text(text = stringResource(R.string.share_target_search_channels))
+ },
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxWidth()
+ )
+
+ Box(
+ modifier = Modifier.weight(1f)
+ ) {
+ val filteredChannels = RevoltAPI.channelCache.values.asSequence().filter {
+ it.name?.contains(
+ channelSearchContent,
+ ignoreCase = true
+ ) == true
+ || ChannelUtils.resolveDMName(it)
+ ?.contains(
+ channelSearchContent,
+ ignoreCase = true
+ ) == true
+ }
+
+ LazyColumn {
+ items(filteredChannels.count()) {
+ val channel = filteredChannels.elementAt(it)
+
+ DrawerChannel(
+ iconType = DrawerChannelIconType.Channel(
+ channel.channelType ?: ChannelType.TextChannel
+ ),
+ name = (if (channel.server != null) "${channel.name} (${RevoltAPI.serverCache[channel.server]?.name})" else channel.name)
+ ?: ChannelUtils.resolveDMName(channel)
+ ?: stringResource(R.string.unknown),
+ selected = selectedChannel == channel.id,
+ hasUnread = false,
+ onClick = {
+ selectedChannel = channel.id
+ },
+ dmPartnerIcon = ChannelUtils.resolveDMPartner(
+ channel
+ )?.let { u -> RevoltAPI.userCache[u] }?.avatar,
+ dmPartnerName = ChannelUtils.resolveDMName(
+ channel
+ ),
+ dmPartnerStatus = ChannelUtils.resolveDMPartner(
+ channel
+ )
+ ?.let { u -> RevoltAPI.userCache[u] }?.status?.presence?.let { p ->
+ presenceFromStatus(
+ p,
+ RevoltAPI.userCache[ChannelUtils.resolveDMPartner(
+ channel
+ )]?.online ?: false
+ )
+ },
+ dmPartnerId = ChannelUtils.resolveDMPartner(
+ channel
+ ),
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .padding(
+ start = 16.dp,
+ end = 16.dp,
+ bottom = 16.dp,
+ top = 8.dp
+ )
+ .clip(MaterialTheme.shapes.medium)
+ ) {
+ AnimatedVisibility(viewModel.attachments.isNotEmpty()) {
+ AttachmentManager(
+ attachments = viewModel.attachments,
+ uploading = viewModel.attachmentsUploading,
+ uploadProgress = viewModel.attachmentProgress,
+ onRemove = {},
+ canRemove = false
+ )
+ }
+
+ NativeMessageField(
+ value = viewModel.messageContent,
+ onValueChange = { viewModel.messageContent = it },
+ canAttach = false,
+ forceSendButton = viewModel.attachments.isNotEmpty(),
+ disabled = viewModel.attachmentsUploading,
+ onAddAttachment = {},
+ onCommitAttachment = {},
+ onPickEmoji = {
+ if (viewModel.activeBottomPane is BottomPane.EmojiPicker) {
+ viewModel.activeBottomPane = BottomPane.None
+ } else {
+ viewModel.activeBottomPane = BottomPane.EmojiPicker
+ }
+ },
+ onSendMessage = {
+ if (selectedChannel == null) {
+ Toast.makeText(
+ context,
+ context.getString(R.string.share_target_select_channel),
+ Toast.LENGTH_SHORT
+ ).show()
+ return@NativeMessageField
+ } else {
+ viewModel.send(selectedChannel!!) {
+ onFinished()
+ }
+ }
+ },
+ channelType = RevoltAPI.channelCache[selectedChannel]?.channelType
+ ?: ChannelType.TextChannel,
+ channelName = RevoltAPI.channelCache[selectedChannel]?.name ?: "",
+ )
+
+ AnimatedVisibility(viewModel.activeBottomPane is BottomPane.EmojiPicker) {
+ BackHandler(enabled = viewModel.activeBottomPane == BottomPane.EmojiPicker) {
+ viewModel.activeBottomPane = BottomPane.None
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(0.5f)
+ .background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
+ .padding(4.dp)
+ ) {
+ EmojiPicker {
+ viewModel.messageContent += " $it"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/chat/revolt/api/internals/ChannelUtils.kt b/app/src/main/java/chat/revolt/api/internals/ChannelUtils.kt
index 3e5e1c60..c75ddd6f 100644
--- a/app/src/main/java/chat/revolt/api/internals/ChannelUtils.kt
+++ b/app/src/main/java/chat/revolt/api/internals/ChannelUtils.kt
@@ -21,7 +21,7 @@ object ChannelUtils {
}
fun resolveDMPartner(channel: Channel): String? {
- return channel.recipients?.first { u -> u != RevoltAPI.selfId }
+ return channel.recipients?.firstOrNull { u -> u != RevoltAPI.selfId }
}
fun categoriseServerFlat(server: Server): List {
diff --git a/app/src/main/java/chat/revolt/components/chat/NativeMessageField.kt b/app/src/main/java/chat/revolt/components/chat/NativeMessageField.kt
index cde64e43..89188657 100644
--- a/app/src/main/java/chat/revolt/components/chat/NativeMessageField.kt
+++ b/app/src/main/java/chat/revolt/components/chat/NativeMessageField.kt
@@ -156,6 +156,7 @@ fun NativeMessageField(
channelName: String,
modifier: Modifier = Modifier,
forceSendButton: Boolean = false,
+ canAttach: Boolean = true,
disabled: Boolean = false,
serverId: String? = null,
channelId: String? = null,
@@ -341,29 +342,31 @@ fun NativeMessageField(
) {
Spacer(modifier = Modifier.width(8.dp))
- Icon(
- when {
- editMode -> Icons.Default.Close
- else -> Icons.Default.Add
- },
- tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
- contentDescription = stringResource(id = R.string.add_attachment_alt),
- modifier = Modifier
- .clip(CircleShape)
- .size(32.dp)
- .clickable {
- when {
- editMode -> cancelEdit()
- else -> {
- // hide keyboard because it's annoying
- clearFocus()
- onAddAttachment()
+ if (canAttach) {
+ Icon(
+ when {
+ editMode -> Icons.Default.Close
+ else -> Icons.Default.Add
+ },
+ tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
+ contentDescription = stringResource(id = R.string.add_attachment_alt),
+ modifier = Modifier
+ .clip(CircleShape)
+ .size(32.dp)
+ .clickable {
+ when {
+ editMode -> cancelEdit()
+ else -> {
+ // hide keyboard because it's annoying
+ clearFocus()
+ onAddAttachment()
+ }
}
}
- }
- .padding(4.dp)
- .testTag("add_attachment")
- )
+ .padding(4.dp)
+ .testTag("add_attachment")
+ )
+ }
AndroidView(
factory = { context ->
@@ -599,6 +602,7 @@ fun NativeMessageFieldPreview() {
channelName = "Test",
modifier = Modifier,
forceSendButton = false,
+ canAttach = true,
disabled = false,
editMode = false,
cancelEdit = {},
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
index c79b220c..7a6ea72a 100644
--- a/app/src/main/java/chat/revolt/components/screens/chat/AttachmentManager.kt
+++ b/app/src/main/java/chat/revolt/components/screens/chat/AttachmentManager.kt
@@ -36,7 +36,8 @@ fun AttachmentManager(
attachments: List,
uploading: Boolean,
uploadProgress: Float = 0f,
- onRemove: (FileArgs) -> Unit
+ onRemove: (FileArgs) -> Unit,
+ canRemove: Boolean = true
) {
val animatedProgress by animateFloatAsState(
targetValue = uploadProgress,
@@ -69,11 +70,13 @@ fun AttachmentManager(
.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)
- )
+ if (canRemove) {
+ Spacer(modifier = Modifier.width(4.dp))
+ Icon(
+ Icons.Default.Close,
+ contentDescription = stringResource(R.string.remove_attachment_alt)
+ )
+ }
}
Spacer(modifier = Modifier.width(8.dp))
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b310b9bf..74693d27 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -529,4 +529,11 @@
What\'s been cooking ✨
Changelog for %1$s
that version
+
+ Share to Revolt
+ Please log in before sharing to Revolt.
+ This is not a valid share intent.
+ This attachment is too large for Revolt (max. $1$s).
+ Search channels
+ Please select a channel to share to.